Skip to content

Commit 93cfa56

Browse files
committed
feat(rpc): generate cookie file at startup
Write <datadir>/.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
1 parent b8cb728 commit 93cfa56

5 files changed

Lines changed: 210 additions & 0 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.

crates/floresta-node/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ bitcoin = { workspace = true }
1313
corepc-types = { workspace = true }
1414
dns-lookup = { workspace = true }
1515
miniscript = { workspace = true, features = ["std"] }
16+
rand = { workspace = true }
1617
rcgen = { workspace = true }
1718
rustreexo = { workspace = true }
1819
serde = { workspace = true }

crates/floresta-node/src/florestad.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ impl Florestad {
460460
// JSON-RPC
461461
#[cfg(feature = "json-rpc")]
462462
{
463+
let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME);
464+
json_rpc::auth::generate_cookie(&cookie_path)?;
465+
info!("RPC cookie file written to {}", cookie_path.display());
466+
463467
let server = tokio::spawn(json_rpc::server::RpcImpl::create(
464468
blockchain_state.clone(),
465469
wallet.clone(),
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// SPDX-License-Identifier: MIT OR Apache-2.0
2+
3+
//! JSON-RPC authentication primitives.
4+
//!
5+
//! Cookie auth mirrors Bitcoin Core's behavior. On startup the server writes a
6+
//! single line of the form `__cookie__:<64-char-hex-token>` to the path the
7+
//! caller passes to [`generate_cookie`]. florestad passes a network-suffixed
8+
//! data directory, so the on-disk layout matches Core's `<base>/[<net>/].cookie`
9+
//! convention (mainnet sits at the root, every other network sits one level
10+
//! deeper).
11+
//!
12+
//! Clients then send `Authorization: Basic <base64(__cookie__:<token>)>`.
13+
//!
14+
//! The token rotates every restart; any pre-existing `.cookie` is silently
15+
//! overwritten.
16+
17+
use std::ffi::OsString;
18+
use std::fs;
19+
use std::fs::OpenOptions;
20+
use std::io;
21+
use std::io::Write;
22+
use std::path::Path;
23+
use std::path::PathBuf;
24+
25+
use bitcoin::hex::DisplayHex;
26+
use rand::Rng;
27+
28+
/// Username literal used for cookie auth (Core convention).
29+
pub(crate) const COOKIE_USER: &str = "__cookie__";
30+
31+
/// Default cookie file name; placed under the net-specific datadir.
32+
pub(crate) const COOKIE_FILE_NAME: &str = ".cookie";
33+
34+
/// Token length in raw random bytes; hex-encoded to 64 ASCII chars.
35+
const COOKIE_TOKEN_BYTES: usize = 32;
36+
37+
/// Generate a fresh cookie and write it to `path` with no trailing newline.
38+
///
39+
/// Writes go to `<path>.tmp` first with mode `0600` on Unix, then atomically
40+
/// rename over `path`. A pre-existing cookie file is silently overwritten.
41+
pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> {
42+
let mut token = [0u8; COOKIE_TOKEN_BYTES];
43+
rand::rng().fill(&mut token);
44+
let auth = format!("{COOKIE_USER}:{}", token.to_lower_hex_string());
45+
46+
let tmp = tmp_path(path);
47+
48+
let mut opts = OpenOptions::new();
49+
opts.write(true).create(true).truncate(true);
50+
#[cfg(unix)]
51+
{
52+
use std::os::unix::fs::OpenOptionsExt;
53+
opts.mode(0o600);
54+
}
55+
56+
let mut file = opts.open(&tmp)?;
57+
file.write_all(auth.as_bytes())?;
58+
drop(file);
59+
60+
fs::rename(&tmp, path)?;
61+
62+
Ok(())
63+
}
64+
65+
fn tmp_path(path: &Path) -> PathBuf {
66+
let mut buf = OsString::from(path);
67+
buf.push(".tmp");
68+
PathBuf::from(buf)
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
use std::fs;
74+
75+
use super::*;
76+
77+
fn tmp_cookie_path(name: &str) -> std::path::PathBuf {
78+
let mut p = std::env::temp_dir();
79+
p.push(format!(
80+
"floresta_cookie_test_{name}_{}",
81+
rand::random::<u32>()
82+
));
83+
p
84+
}
85+
86+
#[test]
87+
fn generate_cookie_writes_expected_format() {
88+
let path = tmp_cookie_path("format");
89+
generate_cookie(&path).unwrap();
90+
91+
let written = fs::read_to_string(&path).unwrap();
92+
assert!(
93+
written.starts_with("__cookie__:"),
94+
"cookie file missing prefix: {written}"
95+
);
96+
let token = written.strip_prefix("__cookie__:").unwrap();
97+
assert_eq!(
98+
token.len(),
99+
64,
100+
"token should be 64 hex chars, got {}",
101+
token.len()
102+
);
103+
assert!(
104+
token
105+
.chars()
106+
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
107+
"token should be lowercase hex: {token}",
108+
);
109+
110+
fs::remove_file(&path).ok();
111+
}
112+
113+
#[test]
114+
fn generate_cookie_writes_no_trailing_newline() {
115+
let path = tmp_cookie_path("newline");
116+
generate_cookie(&path).unwrap();
117+
118+
let bytes = fs::read(&path).unwrap();
119+
assert!(!bytes.ends_with(b"\n"), "file should not end with newline");
120+
121+
fs::remove_file(&path).ok();
122+
}
123+
124+
#[test]
125+
fn generate_cookie_produces_distinct_tokens() {
126+
let path1 = tmp_cookie_path("distinct1");
127+
let path2 = tmp_cookie_path("distinct2");
128+
generate_cookie(&path1).unwrap();
129+
generate_cookie(&path2).unwrap();
130+
let auth1 = fs::read_to_string(&path1).unwrap();
131+
let auth2 = fs::read_to_string(&path2).unwrap();
132+
assert_ne!(
133+
auth1, auth2,
134+
"two consecutive calls produced identical tokens"
135+
);
136+
137+
fs::remove_file(&path1).ok();
138+
fs::remove_file(&path2).ok();
139+
}
140+
141+
#[test]
142+
fn generate_cookie_overwrites_existing_file() {
143+
let path = tmp_cookie_path("overwrite");
144+
fs::write(&path, "stale-content").unwrap();
145+
generate_cookie(&path).unwrap();
146+
let written = fs::read_to_string(&path).unwrap();
147+
assert_ne!(written, "stale-content", "stale content was not replaced");
148+
assert!(
149+
written.starts_with("__cookie__:"),
150+
"replacement is not a cookie line: {written}"
151+
);
152+
153+
fs::remove_file(&path).ok();
154+
}
155+
156+
#[test]
157+
fn generate_cookie_leaves_no_tmp_file() {
158+
let path = tmp_cookie_path("notmp");
159+
generate_cookie(&path).unwrap();
160+
let tmp = tmp_path(&path);
161+
assert!(
162+
!tmp.exists(),
163+
"tmp file should be renamed away, got {tmp:?}"
164+
);
165+
166+
fs::remove_file(&path).ok();
167+
}
168+
169+
#[test]
170+
fn generate_cookie_recovers_from_stale_tmp_file() {
171+
let path = tmp_cookie_path("stale_tmp");
172+
let tmp = tmp_path(&path);
173+
174+
// Simulate a previous run that crashed between create and rename:
175+
// a stale <path>.tmp file lingers with arbitrary content.
176+
fs::write(&tmp, b"partial-from-crashed-run").unwrap();
177+
assert!(tmp.exists(), "precondition: stale tmp must exist");
178+
179+
generate_cookie(&path).unwrap();
180+
181+
let written = fs::read_to_string(&path).unwrap();
182+
assert!(
183+
written.starts_with("__cookie__:"),
184+
"cookie file should hold a fresh cookie, got {written}"
185+
);
186+
assert!(!tmp.exists(), "stale tmp file should be consumed by rename");
187+
188+
fs::remove_file(&path).ok();
189+
}
190+
191+
#[cfg(unix)]
192+
#[test]
193+
fn generate_cookie_sets_owner_only_mode_on_unix() {
194+
use std::os::unix::fs::PermissionsExt;
195+
196+
let path = tmp_cookie_path("perms");
197+
generate_cookie(&path).unwrap();
198+
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
199+
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
200+
201+
fs::remove_file(&path).ok();
202+
}
203+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: MIT OR Apache-2.0
22

3+
pub mod auth;
34
pub mod request;
45
pub mod res;
56
pub mod server;

0 commit comments

Comments
 (0)