Skip to content

Commit 4e30668

Browse files
authored
Merge pull request #269 from benthecarman/codex/loupe-secret-files
Harden local secret file handling
2 parents 6d6d810 + 93e7645 commit 4e30668

10 files changed

Lines changed: 307 additions & 66 deletions

File tree

ldk-server-cli/src/main.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use clap_complete::{generate, Shell};
1515
use hex_conservative::{DisplayHex, FromHex};
1616
use ldk_server_client::client::LdkServerClient;
1717
use ldk_server_client::config::{
18-
get_default_config_path, load_config, resolve_api_key, resolve_base_url, resolve_cert_path,
19-
DEFAULT_GRPC_SERVICE_ADDRESS,
18+
get_default_config_path, load_config, read_tls_certificate, resolve_api_key, resolve_base_url,
19+
resolve_cert_path, DEFAULT_GRPC_SERVICE_ADDRESS,
2020
};
2121
use ldk_server_client::error::LdkServerError;
2222
use ldk_server_client::error::LdkServerErrorCode::{
@@ -655,10 +655,15 @@ async fn main() {
655655
},
656656
};
657657

658-
let api_key = resolve_api_key(cli.api_key, config.as_ref()).unwrap_or_else(|| {
659-
eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key");
660-
std::process::exit(1);
661-
});
658+
let api_key = resolve_api_key(cli.api_key, config.as_ref())
659+
.unwrap_or_else(|e| {
660+
eprintln!("Failed to resolve API key: {e}");
661+
std::process::exit(1);
662+
})
663+
.unwrap_or_else(|| {
664+
eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key");
665+
std::process::exit(1);
666+
});
662667

663668
let base_url = resolve_base_url(cli.base_url, config.as_ref());
664669

@@ -668,8 +673,8 @@ async fn main() {
668673
std::process::exit(1);
669674
});
670675

671-
let server_cert_pem = std::fs::read(&tls_cert_path).unwrap_or_else(|e| {
672-
eprintln!("Failed to read server certificate file '{}': {}", tls_cert_path.display(), e);
676+
let server_cert_pem = read_tls_certificate(&tls_cert_path).unwrap_or_else(|e| {
677+
eprintln!("{e}");
673678
std::process::exit(1);
674679
});
675680

ldk-server-client/src/config.rs

Lines changed: 102 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
//! locating the server's TLS certificate and API key on disk, so multiple clients (CLI, MCP
1414
//! bridge, etc.) can resolve connection credentials in a consistent way.
1515
16-
use std::path::PathBuf;
16+
use std::io::{self, ErrorKind, Read};
17+
use std::path::{Path, PathBuf};
1718

1819
use hex_conservative::DisplayHex;
1920
use serde::{Deserialize, Serialize};
2021

2122
const DEFAULT_CONFIG_FILE: &str = "config.toml";
2223
const DEFAULT_CERT_FILE: &str = "tls.crt";
2324
const API_KEY_FILE: &str = "api_key";
25+
const API_KEY_LEN: usize = 32;
26+
const CONFIG_FILE_SIZE_LIMIT: usize = 1024 * 1024;
27+
const TLS_CERT_FILE_SIZE_LIMIT: usize = 1024 * 1024;
2428

2529
/// Default address of the `ldk-server` gRPC endpoint when no explicit value is configured.
2630
pub const DEFAULT_GRPC_SERVICE_ADDRESS: &str = "127.0.0.1:3536";
@@ -124,13 +128,21 @@ impl Config {
124128
}
125129

126130
/// Reads and parses the `ldk-server` configuration file at `path`.
127-
pub fn load_config(path: &PathBuf) -> Result<Config, String> {
128-
let contents = std::fs::read_to_string(path)
131+
pub fn load_config(path: &Path) -> Result<Config, String> {
132+
let contents = read_to_string_with_limit(path, CONFIG_FILE_SIZE_LIMIT)
129133
.map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?;
130134
toml::from_str(&contents)
131135
.map_err(|e| format!("Failed to parse config file '{}': {}", path.display(), e))
132136
}
133137

138+
/// Reads the server TLS certificate at `path`.
139+
///
140+
/// Returns an error if the file exceeds 1 MiB.
141+
pub fn read_tls_certificate(path: &Path) -> Result<Vec<u8>, String> {
142+
read_with_limit(path, TLS_CERT_FILE_SIZE_LIMIT)
143+
.map_err(|e| format!("Failed to read server certificate file '{}': {e}", path.display()))
144+
}
145+
134146
/// Resolves the base URL of the `ldk-server` gRPC endpoint.
135147
///
136148
/// Prefers `override_url`, falls back to the configuration file, and finally to
@@ -146,18 +158,65 @@ pub fn resolve_base_url(override_url: Option<String>, config: Option<&Config>) -
146158
/// Prefers `override_key`, falls back to reading the API key file from the configured storage
147159
/// directory, and finally from the OS-specific default data directory. The raw bytes read from
148160
/// disk are lower-hex encoded before being returned.
149-
pub fn resolve_api_key(override_key: Option<String>, config: Option<&Config>) -> Option<String> {
150-
override_key.or_else(|| {
151-
let network =
152-
config.and_then(|c| c.network().ok()).unwrap_or_else(|| "bitcoin".to_string());
153-
storage_dir(config)
154-
.map(|dir| api_key_path_for_storage_dir(dir, &network))
155-
.and_then(|path| std::fs::read(&path).ok())
156-
.or_else(|| {
157-
get_default_api_key_path(&network).and_then(|path| std::fs::read(&path).ok())
158-
})
159-
.map(|bytes| bytes.to_lower_hex_string())
160-
})
161+
///
162+
/// Returns an error if a candidate API key file exists but cannot be read or does not contain
163+
/// exactly 32 bytes.
164+
pub fn resolve_api_key(
165+
override_key: Option<String>, config: Option<&Config>,
166+
) -> Result<Option<String>, String> {
167+
if override_key.is_some() {
168+
return Ok(override_key);
169+
}
170+
171+
let network = config.and_then(|c| c.network().ok()).unwrap_or_else(|| "bitcoin".to_string());
172+
if let Some(dir) = storage_dir(config) {
173+
let path = api_key_path_for_storage_dir(dir, &network);
174+
if let Some(api_key) = read_api_key(&path)? {
175+
return Ok(Some(api_key));
176+
}
177+
}
178+
179+
match get_default_api_key_path(&network) {
180+
Some(path) => read_api_key(&path),
181+
None => Ok(None),
182+
}
183+
}
184+
185+
fn read_api_key(path: &Path) -> Result<Option<String>, String> {
186+
let file = match std::fs::File::open(path) {
187+
Ok(file) => file,
188+
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
189+
Err(e) => return Err(format!("Failed to read API key file '{}': {e}", path.display())),
190+
};
191+
let mut bytes = Vec::with_capacity(API_KEY_LEN + 1);
192+
file.take((API_KEY_LEN + 1) as u64)
193+
.read_to_end(&mut bytes)
194+
.map_err(|e| format!("Failed to read API key file '{}': {e}", path.display()))?;
195+
if bytes.len() != API_KEY_LEN {
196+
return Err(format!(
197+
"API key file '{}' must contain exactly {API_KEY_LEN} bytes",
198+
path.display()
199+
));
200+
}
201+
Ok(Some(bytes.to_lower_hex_string()))
202+
}
203+
204+
fn read_with_limit(path: &Path, limit: usize) -> io::Result<Vec<u8>> {
205+
let file = std::fs::File::open(path)?;
206+
let mut contents = Vec::new();
207+
file.take(limit.saturating_add(1) as u64).read_to_end(&mut contents)?;
208+
if contents.len() > limit {
209+
return Err(io::Error::new(
210+
io::ErrorKind::InvalidData,
211+
format!("File '{}' exceeds the {limit} byte limit", path.display()),
212+
));
213+
}
214+
Ok(contents)
215+
}
216+
217+
fn read_to_string_with_limit(path: &Path, limit: usize) -> io::Result<String> {
218+
String::from_utf8(read_with_limit(path, limit)?)
219+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
161220
}
162221

163222
/// Resolves the path to the server's TLS certificate (PEM).
@@ -187,7 +246,10 @@ fn default_grpc_service_address() -> String {
187246

188247
#[cfg(test)]
189248
mod tests {
190-
use super::{resolve_base_url, Config, DEFAULT_GRPC_SERVICE_ADDRESS};
249+
use super::{
250+
load_config, read_tls_certificate, resolve_base_url, Config, CONFIG_FILE_SIZE_LIMIT,
251+
DEFAULT_GRPC_SERVICE_ADDRESS, TLS_CERT_FILE_SIZE_LIMIT,
252+
};
191253

192254
#[test]
193255
fn config_defaults_grpc_service_address() {
@@ -282,4 +344,28 @@ mod tests {
282344
fn resolve_base_url_falls_back_to_default() {
283345
assert_eq!(resolve_base_url(None, None), DEFAULT_GRPC_SERVICE_ADDRESS);
284346
}
347+
348+
#[test]
349+
fn read_tls_certificate_rejects_oversized_file() {
350+
let path = std::env::temp_dir()
351+
.join(format!("ldk-server-client-oversized-cert-{}", std::process::id()));
352+
std::fs::write(&path, vec![0; TLS_CERT_FILE_SIZE_LIMIT + 1]).unwrap();
353+
354+
let error = read_tls_certificate(&path).unwrap_err();
355+
assert!(error.contains("exceeds"));
356+
357+
std::fs::remove_file(path).unwrap();
358+
}
359+
360+
#[test]
361+
fn load_config_rejects_oversized_file() {
362+
let path = std::env::temp_dir()
363+
.join(format!("ldk-server-client-oversized-config-{}", std::process::id()));
364+
std::fs::write(&path, vec![b'a'; CONFIG_FILE_SIZE_LIMIT + 1]).unwrap();
365+
366+
let error = load_config(&path).unwrap_err();
367+
assert!(error.contains("exceeds"));
368+
369+
std::fs::remove_file(path).unwrap();
370+
}
285371
}

ldk-server-mcp/src/config.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
use std::path::PathBuf;
1111

1212
use ldk_server_client::config::{
13-
get_default_config_path, load_config, resolve_api_key, resolve_base_url, resolve_cert_path,
13+
get_default_config_path, load_config, read_tls_certificate, resolve_api_key, resolve_base_url,
14+
resolve_cert_path,
1415
};
1516

1617
pub struct ResolvedConfig {
@@ -39,7 +40,7 @@ pub fn resolve_config(config_path: Option<String>) -> Result<ResolvedConfig, Str
3940

4041
let base_url = resolve_base_url(env_base_url, config.as_ref());
4142

42-
let api_key = resolve_api_key(env_api_key, config.as_ref()).ok_or_else(
43+
let api_key = resolve_api_key(env_api_key, config.as_ref())?.ok_or_else(
4344
|| "API key not provided. Set LDK_API_KEY or ensure the api_key file exists at ~/.ldk-server/[network]/api_key".to_string()
4445
)?;
4546

@@ -48,9 +49,7 @@ pub fn resolve_config(config_path: Option<String>) -> Result<ResolvedConfig, Str
4849
.to_string()
4950
})?;
5051

51-
let tls_cert_pem = std::fs::read(&tls_cert_path).map_err(|e| {
52-
format!("Failed to read server certificate file '{}': {}", tls_cert_path.display(), e)
53-
})?;
52+
let tls_cert_pem = read_tls_certificate(&tls_cert_path)?;
5453

5554
Ok(ResolvedConfig { base_url, api_key, tls_cert_pem })
5655
}
@@ -203,7 +202,7 @@ mod tests {
203202

204203
let cert_path = custom_storage.join("tls.crt");
205204
std::fs::write(&cert_path, b"storage-cert").unwrap();
206-
std::fs::write(custom_storage.join("regtest").join("api_key"), [0xAB, 0xCD]).unwrap();
205+
std::fs::write(custom_storage.join("regtest").join("api_key"), [0xAB; 32]).unwrap();
207206

208207
std::fs::write(
209208
&config_path,
@@ -226,7 +225,7 @@ mod tests {
226225
let resolved = resolve_config(Some(config_path.display().to_string())).unwrap();
227226

228227
assert_eq!(resolved.base_url, DEFAULT_GRPC_SERVICE_ADDRESS);
229-
assert_eq!(resolved.api_key, "abcd");
228+
assert_eq!(resolved.api_key, "ab".repeat(32));
230229
assert_eq!(resolved.tls_cert_pem, b"storage-cert");
231230

232231
std::fs::remove_dir_all(temp_dir).unwrap();

ldk-server/src/io/persist/sqlite_store/mod.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10+
use std::fs::OpenOptions;
11+
use std::io;
12+
use std::os::unix::fs::OpenOptionsExt;
1013
use std::path::PathBuf;
1114
use std::sync::{Arc, Mutex};
12-
use std::{fs, io};
1315

1416
use ldk_node::lightning::types::string::PrintableString;
1517
use rusqlite::{named_params, Connection};
1618

1719
use crate::io::persist::paginated_kv_store::{ListResponse, PaginatedKVStore};
1820
use crate::io::utils::check_namespace_key_validity;
21+
use crate::util::create_dir_all_private;
1922

2023
/// The default database file name.
2124
pub const DEFAULT_SQLITE_DB_FILE_NAME: &str = "ldk_server_data.sqlite";
@@ -48,7 +51,7 @@ impl SqliteStore {
4851
let paginated_kv_table_name =
4952
paginated_kv_table_name.unwrap_or(DEFAULT_PAGINATED_KV_TABLE_NAME.to_string());
5053

51-
fs::create_dir_all(data_dir.clone()).map_err(|e| {
54+
create_dir_all_private(&data_dir).map_err(|e| {
5255
let msg = format!(
5356
"Failed to create database destination directory {}: {}",
5457
data_dir.display(),
@@ -58,6 +61,15 @@ impl SqliteStore {
5861
})?;
5962
let mut db_file_path = data_dir;
6063
db_file_path.push(db_file_name);
64+
match OpenOptions::new().create_new(true).write(true).mode(0o600).open(&db_file_path) {
65+
Ok(_) => {},
66+
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {},
67+
Err(e) => {
68+
let msg =
69+
format!("Failed to create database file {}: {}", db_file_path.display(), e);
70+
return Err(io::Error::other(msg));
71+
},
72+
}
6173

6274
let connection = Connection::open(db_file_path.clone()).map_err(|e| {
6375
let msg =

ldk-server/src/main.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod util;
1414

1515
use std::collections::HashSet;
1616
use std::fs;
17+
use std::io::Read;
1718
use std::path::{Path, PathBuf};
1819
use std::sync::Arc;
1920
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -54,9 +55,10 @@ use crate::util::logger::{LogConfig, ServerLogger};
5455
use crate::util::metrics::Metrics;
5556
use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto};
5657
use crate::util::tls::get_or_generate_tls_config;
57-
use crate::util::{systemd, write_new};
58+
use crate::util::{create_dir_all_private, systemd, write_new};
5859

5960
const API_KEY_FILE: &str = "api_key";
61+
const API_KEY_LEN: usize = 32;
6062
const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")");
6163

6264
pub fn get_default_data_dir() -> Option<PathBuf> {
@@ -969,15 +971,31 @@ fn upsert_payment_details(
969971
fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result<String> {
970972
let api_key_path = storage_dir.join(API_KEY_FILE);
971973

972-
if api_key_path.exists() {
973-
let key_bytes = fs::read(&api_key_path)?;
974+
let file = match fs::File::open(&api_key_path) {
975+
Ok(file) => Some(file),
976+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
977+
Err(e) => return Err(e),
978+
};
979+
980+
if let Some(file) = file {
981+
let mut key_bytes = Vec::with_capacity(API_KEY_LEN + 1);
982+
file.take((API_KEY_LEN + 1) as u64).read_to_end(&mut key_bytes)?;
983+
if key_bytes.len() != API_KEY_LEN {
984+
return Err(std::io::Error::new(
985+
std::io::ErrorKind::InvalidData,
986+
format!(
987+
"API key file '{}' must contain exactly {API_KEY_LEN} bytes",
988+
api_key_path.display()
989+
),
990+
));
991+
}
974992
Ok(key_bytes.to_lower_hex_string())
975993
} else {
976994
// Ensure the storage directory exists
977-
fs::create_dir_all(storage_dir)?;
995+
create_dir_all_private(storage_dir)?;
978996

979997
// Generate a 32-byte random API key
980-
let mut key_bytes = [0u8; 32];
998+
let mut key_bytes = [0u8; API_KEY_LEN];
981999
getrandom::getrandom(&mut key_bytes).map_err(std::io::Error::other)?;
9821000

9831001
write_new(&api_key_path, &key_bytes, 0o400)?;
@@ -1005,6 +1023,23 @@ mod tests {
10051023

10061024
use super::*;
10071025

1026+
#[test]
1027+
fn load_api_key_rejects_invalid_lengths() {
1028+
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
1029+
let dir = std::env::temp_dir()
1030+
.join(format!("ldk-server-api-key-length-{}-{nonce}", std::process::id()));
1031+
fs::create_dir_all(&dir).unwrap();
1032+
let path = dir.join(API_KEY_FILE);
1033+
1034+
for len in [0, 1, API_KEY_LEN - 1, API_KEY_LEN + 1] {
1035+
fs::write(&path, vec![0x42; len]).unwrap();
1036+
let error = load_or_generate_api_key(&dir).unwrap_err();
1037+
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
1038+
}
1039+
1040+
fs::remove_dir_all(dir).unwrap();
1041+
}
1042+
10081043
#[test]
10091044
fn test_is_channel_open_failure_classification() {
10101045
assert!(is_channel_open_failure(Some(&ClosureReason::FundingTimedOut)));

0 commit comments

Comments
 (0)