diff --git a/contrib/ldk-server-config.toml b/contrib/ldk-server-config.toml index 486baa8a..d404d7b6 100644 --- a/contrib/ldk-server-config.toml +++ b/contrib/ldk-server-config.toml @@ -8,6 +8,15 @@ alias = "ldk_server" # Lightning node alias #pathfinding_scores_source_url = "" # External Pathfinding Scores Source #rgs_server_url = "https://rapidsync.lightningdevkit.org/snapshot/v2/" # Optional: RGS URL for rapid gossip sync +# Node entropy settings +[node.entropy] +# Path to a BIP39 mnemonic file. If unset and no legacy `keys_seed` file exists, a fresh +# 24-word mnemonic is generated on first start. Defaults to "/keys_mnemonic". +#mnemonic_file = "/tmp/ldk-server/keys_mnemonic" +# Legacy: path to a raw 64-byte seed file used by ldk-server installs initialized before +# BIP39 mnemonic support. Mutually exclusive with `mnemonic_file`. +#seed_file = "/tmp/ldk-server/keys_seed" + # Storage settings [storage.disk] dir_path = "/tmp/ldk-server/" # Path for LDK and BDK data persistence, optional, defaults to ~/Library/Application Support/ldk-server/ on macOS, ~/.ldk-server/ on Linux diff --git a/docs/configuration.md b/docs/configuration.md index 40f2927d..d2833634 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,7 +127,8 @@ full setup. ``` / - keys_seed # Node entropy/seed + keys_mnemonic # BIP39 mnemonic (default for new installs) + keys_seed # Legacy raw seed (only present on installs initialized before mnemonic support) tls.crt # TLS certificate (PEM) tls.key # TLS private key (PEM) / # e.g., bitcoin/, regtest/, signet/ @@ -137,6 +138,20 @@ full setup. ldk_server_data.sqlite # Payment and forwarding history ``` -The `keys_seed` file is the node's master secret, required to recover on-chain funds. -`ldk_node_data.sqlite` holds channel state, both are required to recover channel funds. See -[Operations - Backups](operations.md#backups) for backup guidance. +The mnemonic (or, for legacy installs, the raw seed) is the node's master secret, required to +recover on-chain funds. `ldk_node_data.sqlite` holds channel state, both are required to recover +channel funds. See [Operations - Backups](operations.md#backups) for backup guidance. + +### Node entropy (`[node.entropy]`) + +By default, ldk-server reads or generates a 24-word BIP39 mnemonic at `/keys_mnemonic`, +which can be imported into any standard BIP39-compatible wallet to recover on-chain funds. The +defaults can be overridden under `[node.entropy]`: + +- `mnemonic_file`: path to the BIP39 mnemonic file. Defaults to `/keys_mnemonic`. If + the file does not exist on first start, a fresh 24-word mnemonic is generated and written. +- `seed_file`: path to a raw 64-byte seed file. Provided for backwards compatibility with installs + initialized before BIP39 mnemonic support. Mutually exclusive with `mnemonic_file`. + +For backwards compatibility, if neither field is configured and a `keys_seed` file exists at the +storage root, ldk-server will continue to use it. diff --git a/docs/operations.md b/docs/operations.md index 1a8935dd..8587630f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,7 +48,8 @@ setup): | File | Priority | Description | |----------------------------------------|--------------|----------------------------------------------------------------------------| -| `/keys_seed` | **Critical** | Node identity and master secret. Required to recover on-chain funds. | +| `/keys_mnemonic` | **Critical** | BIP39 mnemonic. Required to recover on-chain funds. Default for new installs. | +| `/keys_seed` | **Critical** | Legacy raw seed file. Only present on installs initialized before mnemonic support. | | `/ldk_node_data.sqlite` | **Critical** | Channel state and on-chain wallet data. Required to recover channel funds. | | `/ldk_server_data.sqlite` | Nice-to-have | Payment and forwarding history | @@ -162,6 +163,6 @@ Data is stored in per-network subdirectories (`bitcoin/`, `testnet/`, `signet/`, etc.) under the storage root. This means you can run multiple networks from one storage directory without conflicts. -The `keys_seed` file is shared across networks (stored at the storage root, not per-network). -Keys are split by network at the derivation path level, so the same seed will produce -different keys. +The `keys_mnemonic` file (or, on legacy installs, `keys_seed`) is shared across networks +(stored at the storage root, not per-network). Keys are split by network at the derivation +path level, so the same mnemonic/seed will produce different keys. diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 30807541..a6db3e54 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -26,7 +26,6 @@ use hyper::server::conn::http2; use hyper_util::rt::{TokioExecutor, TokioIo}; use ldk_node::bitcoin::Network; use ldk_node::config::Config; -use ldk_node::entropy::NodeEntropy; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::{Builder, Event, Node}; use ldk_server_grpc::events; @@ -204,11 +203,13 @@ fn main() { builder.set_runtime(runtime.handle().clone()); - let seed_path = storage_dir.join("keys_seed").to_str().unwrap().to_string(); - let node_entropy = match NodeEntropy::from_seed_path(seed_path) { + let node_entropy = match crate::util::entropy::load_or_generate_node_entropy( + &storage_dir, + &config_file.entropy, + ) { Ok(entropy) => entropy, Err(e) => { - error!("Failed to load or generate seed: {e}"); + error!("Failed to load or generate node entropy: {e}"); std::process::exit(-1); }, }; diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 22e3b61b..7c35b5da 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -61,6 +61,18 @@ pub struct Config { pub metrics_username: Option, pub metrics_password: Option, pub tor_config: Option, + pub entropy: EntropyConfig, +} + +/// Configuration for the node's entropy source. +/// +/// When both `mnemonic_file` and `seed_file` are unset, the node defaults to loading or +/// generating a BIP39 mnemonic at `/keys_mnemonic`. If a legacy raw-seed file +/// exists at `/keys_seed`, it is used for backwards compatibility. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EntropyConfig { + pub mnemonic_file: Option, + pub seed_file: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -114,6 +126,7 @@ struct ConfigBuilder { metrics_username: Option, metrics_password: Option, tor_proxy_address: Option, + entropy: EntropyConfig, } impl ConfigBuilder { @@ -130,6 +143,11 @@ impl ConfigBuilder { self.pathfinding_scores_source_url = node.pathfinding_scores_source_url.or(self.pathfinding_scores_source_url.clone()); self.rgs_server_url = node.rgs_server_url.or(self.rgs_server_url.clone()); + if let Some(entropy) = node.entropy { + self.entropy.mnemonic_file = + entropy.mnemonic_file.or(self.entropy.mnemonic_file.take()); + self.entropy.seed_file = entropy.seed_file.or(self.entropy.seed_file.take()); + } } if let Some(storage) = toml.storage { @@ -402,6 +420,13 @@ impl ConfigBuilder { }) .transpose()?; + if self.entropy.mnemonic_file.is_some() && self.entropy.seed_file.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Only one of `node.entropy.mnemonic_file` and `node.entropy.seed_file` may be configured.".to_string(), + )); + } + Ok(Config { network, listening_addrs, @@ -422,6 +447,7 @@ impl ConfigBuilder { metrics_username, metrics_password, tor_config: tor_proxy_address.map(|proxy_address| TorConfig { proxy_address }), + entropy: self.entropy, }) } } @@ -450,6 +476,13 @@ struct NodeConfig { alias: Option, pathfinding_scores_source_url: Option, rgs_server_url: Option, + entropy: Option, +} + +#[derive(Deserialize, Serialize)] +struct NodeEntropyTomlConfig { + mnemonic_file: Option, + seed_file: Option, } #[derive(Deserialize, Serialize)] @@ -936,6 +969,7 @@ mod tests { tor_config: Some(TorConfig { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1241,6 +1275,7 @@ mod tests { metrics_username: None, metrics_password: None, tor_config: None, + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1350,6 +1385,7 @@ mod tests { tor_config: Some(TorConfig { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1501,4 +1537,63 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } + + #[test] + fn test_parses_node_entropy_section() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_parses_node_entropy_section.toml"; + + let toml_config = r#" + [node] + network = "regtest" + grpc_service_address = "127.0.0.1:3002" + + [node.entropy] + mnemonic_file = "/some/path/keys_mnemonic" + + [bitcoind] + rpc_address = "127.0.0.1:8332" + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let mut args_config = empty_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + let config = load_config(&args_config).unwrap(); + assert_eq!(config.entropy.mnemonic_file, Some("/some/path/keys_mnemonic".to_string())); + assert_eq!(config.entropy.seed_file, None); + } + + #[test] + fn test_rejects_both_mnemonic_and_seed_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_rejects_both_mnemonic_and_seed_file.toml"; + + let toml_config = r#" + [node] + network = "regtest" + grpc_service_address = "127.0.0.1:3002" + + [node.entropy] + mnemonic_file = "/some/path/keys_mnemonic" + seed_file = "/some/path/keys_seed" + + [bitcoind] + rpc_address = "127.0.0.1:8332" + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + "#; + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + let mut args_config = empty_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + let err = load_config(&args_config).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!(err.to_string().contains("Only one of")); + } } diff --git a/ldk-server/src/util/entropy.rs b/ldk-server/src/util/entropy.rs new file mode 100644 index 00000000..497602b6 --- /dev/null +++ b/ldk-server/src/util/entropy.rs @@ -0,0 +1,204 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs; +use std::io::{self, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use ldk_node::bip39::Mnemonic; +use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; +use log::info; + +use crate::util::config::EntropyConfig; + +const DEFAULT_MNEMONIC_FILE: &str = "keys_mnemonic"; +const LEGACY_SEED_FILE: &str = "keys_seed"; + +pub(crate) fn load_or_generate_node_entropy( + storage_dir: &Path, entropy_config: &EntropyConfig, +) -> io::Result { + if let Some(seed_file) = &entropy_config.seed_file { + info!("Loading node entropy from raw seed file at {}", seed_file); + return NodeEntropy::from_seed_path(seed_file.clone()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string())); + } + + let legacy_seed_path = storage_dir.join(LEGACY_SEED_FILE); + if entropy_config.mnemonic_file.is_none() && legacy_seed_path.exists() { + info!( + "Detected legacy raw seed file at {}; continuing to use it. New installs use a BIP39 mnemonic by default.", + legacy_seed_path.display() + ); + return NodeEntropy::from_seed_path(legacy_seed_path.to_string_lossy().into_owned()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string())); + } + + let mnemonic_path = match &entropy_config.mnemonic_file { + Some(p) => PathBuf::from(p), + None => storage_dir.join(DEFAULT_MNEMONIC_FILE), + }; + + let mnemonic = if mnemonic_path.exists() { + let raw = fs::read_to_string(&mnemonic_path)?; + Mnemonic::from_str(raw.trim()).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid BIP39 mnemonic in {}: {}", mnemonic_path.display(), e), + ) + })? + } else { + if let Some(parent) = mnemonic_path.parent() { + fs::create_dir_all(parent)?; + } + let mnemonic = generate_entropy_mnemonic(None); + write_mnemonic_file(&mnemonic_path, &mnemonic)?; + info!( + "Generated new BIP39 mnemonic at {}. Back up this file securely — it is required to recover on-chain funds.", + mnemonic_path.display() + ); + mnemonic + }; + + Ok(NodeEntropy::from_bip39_mnemonic(mnemonic, None)) +} + +fn write_mnemonic_file(path: &Path, mnemonic: &Mnemonic) -> io::Result<()> { + let mut f = fs::OpenOptions::new().create_new(true).write(true).open(path)?; + writeln!(f, "{}", mnemonic)?; + f.sync_all()?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::MetadataExt; + + const KNOWN_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + fn tempdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ldk-server-entropy-test-{}-{}", + tag, + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn generates_mnemonic_on_fresh_start() { + let dir = tempdir("fresh"); + let cfg = EntropyConfig::default(); + + load_or_generate_node_entropy(&dir, &cfg).unwrap(); + + let mnemonic_path = dir.join(DEFAULT_MNEMONIC_FILE); + assert!(mnemonic_path.exists(), "keys_mnemonic was not created"); + + let perms = fs::metadata(&mnemonic_path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600, "expected 0600 permissions"); + + let content = fs::read_to_string(&mnemonic_path).unwrap(); + let word_count = content.trim().split_whitespace().count(); + assert_eq!(word_count, 24, "expected 24-word mnemonic, got {}", word_count); + + let mtime_before = fs::metadata(&mnemonic_path).unwrap().mtime(); + load_or_generate_node_entropy(&dir, &cfg).unwrap(); + let mtime_after = fs::metadata(&mnemonic_path).unwrap().mtime(); + assert_eq!(mtime_before, mtime_after, "mnemonic file was rewritten on second call"); + } + + #[test] + fn rereads_existing_mnemonic_without_mutation() { + let dir = tempdir("reread"); + let mnemonic_path = dir.join(DEFAULT_MNEMONIC_FILE); + fs::write(&mnemonic_path, format!("{}\n", KNOWN_MNEMONIC)).unwrap(); + let bytes_before = fs::read(&mnemonic_path).unwrap(); + + load_or_generate_node_entropy(&dir, &EntropyConfig::default()).unwrap(); + + let bytes_after = fs::read(&mnemonic_path).unwrap(); + assert_eq!(bytes_before, bytes_after, "mnemonic file content changed"); + } + + #[test] + fn auto_detects_legacy_keys_seed() { + let dir = tempdir("legacy"); + let legacy_path = dir.join(LEGACY_SEED_FILE); + fs::write(&legacy_path, vec![0x42u8; 64]).unwrap(); + + load_or_generate_node_entropy(&dir, &EntropyConfig::default()).unwrap(); + + assert!( + !dir.join(DEFAULT_MNEMONIC_FILE).exists(), + "keys_mnemonic was unexpectedly created" + ); + assert!(legacy_path.exists(), "legacy keys_seed was removed"); + } + + #[test] + fn explicit_seed_file_used_directly() { + let dir = tempdir("explicit-seed"); + let custom_seed = dir.join("custom-seed.bin"); + fs::write(&custom_seed, vec![0x17u8; 64]).unwrap(); + + let cfg = EntropyConfig { + seed_file: Some(custom_seed.to_string_lossy().into_owned()), + mnemonic_file: None, + }; + + load_or_generate_node_entropy(&dir, &cfg).unwrap(); + + assert!( + !dir.join(DEFAULT_MNEMONIC_FILE).exists(), + "keys_mnemonic was created despite seed_file being set" + ); + } + + #[test] + fn rejects_invalid_mnemonic_file() { + let dir = tempdir("invalid"); + fs::write( + dir.join(DEFAULT_MNEMONIC_FILE), + "these words are definitely not a valid bip39 phrase at all nope", + ) + .unwrap(); + + let err = load_or_generate_node_entropy(&dir, &EntropyConfig::default()).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn custom_mnemonic_path_respected() { + let dir = tempdir("custom-mnemonic"); + let custom_path = dir.join("elsewhere").join("my_mnemonic"); + let cfg = EntropyConfig { + mnemonic_file: Some(custom_path.to_string_lossy().into_owned()), + seed_file: None, + }; + + load_or_generate_node_entropy(&dir, &cfg).unwrap(); + + assert!(custom_path.exists(), "custom mnemonic file was not created"); + assert!( + !dir.join(DEFAULT_MNEMONIC_FILE).exists(), + "default keys_mnemonic was unexpectedly created" + ); + + let content_before = fs::read(&custom_path).unwrap(); + load_or_generate_node_entropy(&dir, &cfg).unwrap(); + let content_after = fs::read(&custom_path).unwrap(); + assert_eq!(content_before, content_after); + } +} diff --git a/ldk-server/src/util/mod.rs b/ldk-server/src/util/mod.rs index a57dbd00..20a34679 100644 --- a/ldk-server/src/util/mod.rs +++ b/ldk-server/src/util/mod.rs @@ -8,6 +8,7 @@ // licenses. pub(crate) mod config; +pub(crate) mod entropy; pub(crate) mod logger; pub(crate) mod metrics; pub(crate) mod proto_adapter;