From aa89374517f6bc94cd379f7add2f549bb1dafcb1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Apr 2026 15:13:32 +0200 Subject: [PATCH 1/4] Default to BIP39 mnemonic for node entropy with raw-seed fallback Previously ldk-server unconditionally loaded its node entropy from a raw 64-byte file at `/keys_seed`, which is opaque and cannot be imported into other wallets. Switch the default to a BIP39 mnemonic written to `/keys_mnemonic`, so operators can back up their node identity as a 24-word phrase and recover on-chain funds with any standard BIP39-compatible wallet. Add a `[node.entropy]` config section with two mutually exclusive fields: - `mnemonic_file`: path to the BIP39 mnemonic file (defaults to `/keys_mnemonic`). - `seed_file`: legacy raw-seed file path, for installs initialized before this change. For backwards compatibility, if neither field is set and a `keys_seed` file already exists at the storage root, ldk-server continues to use it. No implicit migration: a raw 64-byte seed cannot be reversed back into its source mnemonic. Generated with the assistance of AI (Claude). Co-Authored-By: HAL 9000 --- ldk-server/src/main.rs | 9 +- ldk-server/src/util/config.rs | 95 +++++++++++++++ ldk-server/src/util/entropy.rs | 204 +++++++++++++++++++++++++++++++++ ldk-server/src/util/mod.rs | 1 + 4 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 ldk-server/src/util/entropy.rs diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index ba64e7ce..1ecd0998 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -27,7 +27,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::events::ClosureReason; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning::ln::types::ChannelId; @@ -213,11 +212,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 61af98f5..48b96333 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -64,6 +64,18 @@ pub struct Config { pub metrics_password: Option, pub tor_config: Option, pub hrn_config: HumanReadableNamesConfig, + 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)] @@ -119,6 +131,7 @@ struct ConfigBuilder { metrics_password: Option, tor_proxy_address: Option, hrn: Option, + entropy: EntropyConfig, } impl ConfigBuilder { @@ -137,6 +150,11 @@ impl ConfigBuilder { self.async_payments_role = node.async_payments_role.or(self.async_payments_role.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 { @@ -432,6 +450,13 @@ impl ConfigBuilder { None => HumanReadableNamesConfig::default(), }; + 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, @@ -454,6 +479,7 @@ impl ConfigBuilder { metrics_password, tor_config: tor_proxy_address.map(|proxy_address| TorConfig { proxy_address }), hrn_config, + entropy: self.entropy, }) } } @@ -484,6 +510,13 @@ struct NodeConfig { pathfinding_scores_source_url: Option, async_payments_role: Option, rgs_server_url: Option, + entropy: Option, +} + +#[derive(Deserialize, Serialize)] +struct NodeEntropyTomlConfig { + mnemonic_file: Option, + seed_file: Option, } #[derive(Deserialize, Serialize)] @@ -1087,6 +1120,7 @@ mod tests { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), hrn_config: HumanReadableNamesConfig::default(), + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1395,6 +1429,7 @@ mod tests { metrics_password: None, tor_config: None, hrn_config: HumanReadableNamesConfig::default(), + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1507,6 +1542,7 @@ mod tests { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), hrn_config: HumanReadableNamesConfig::default(), + entropy: EntropyConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1789,4 +1825,63 @@ mod tests { ); assert!(parse_dns_server_address("invalid@address").is_err()); } + + #[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..6e062c33 --- /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::{OpenOptionsExt, 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).mode(0o600).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; From bfda93d82acbc91410a1ee515b132e25ba9945da Mon Sep 17 00:00:00 2001 From: tnull Date: Thu, 21 May 2026 12:31:29 +0200 Subject: [PATCH 2/4] f - Tighten entropy source configuration Review feedback preferred a single explicit entropy source over two mutually exclusive Options. Convert the config to an enum and add CLI and environment overrides so non-TOML deployments can select the entropy path. Require legacy raw seed users to opt in via seed_file so default startup consistently uses the mnemonic path. Set permissions before the final sync so file contents and permission metadata are flushed together. Co-Authored-By: HAL 9000 --- ldk-server/src/util/config.rs | 148 ++++++++++++++++++++++++++++----- ldk-server/src/util/entropy.rs | 47 ++++------- 2 files changed, 140 insertions(+), 55 deletions(-) diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 48b96333..53740d52 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -69,13 +69,14 @@ pub struct Config { /// 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. +/// When unset, the node defaults to loading or generating a BIP39 mnemonic at +/// `/keys_mnemonic`. #[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct EntropyConfig { - pub mnemonic_file: Option, - pub seed_file: Option, +pub enum EntropyConfig { + #[default] + Default, + MnemonicFile(String), + SeedFile(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -135,7 +136,7 @@ struct ConfigBuilder { } impl ConfigBuilder { - fn merge_toml(&mut self, toml: TomlConfig) { + fn merge_toml(&mut self, toml: TomlConfig) -> io::Result<()> { if let Some(node) = toml.node { self.network = node.network.or(self.network); self.listening_addresses = @@ -151,9 +152,7 @@ impl ConfigBuilder { node.async_payments_role.or(self.async_payments_role.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()); + self.entropy = EntropyConfig::from_paths(entropy.mnemonic_file, entropy.seed_file)?; } } @@ -209,9 +208,11 @@ impl ConfigBuilder { if let Some(hrn) = toml.hrn { self.hrn = Some(hrn); } + + Ok(()) } - fn merge_args(&mut self, args: &ArgsConfig) { + fn merge_args(&mut self, args: &ArgsConfig) -> io::Result<()> { if let Some(network) = args.node_network { self.network = Some(network); } @@ -275,6 +276,15 @@ impl ConfigBuilder { if let Some(tor_proxy_address) = &args.tor_proxy_address { self.tor_proxy_address = Some(tor_proxy_address.clone()); } + + if args.node_entropy_mnemonic_file.is_some() || args.node_entropy_seed_file.is_some() { + self.entropy = EntropyConfig::from_paths( + args.node_entropy_mnemonic_file.clone(), + args.node_entropy_seed_file.clone(), + )?; + } + + Ok(()) } fn build(self) -> io::Result { @@ -450,13 +460,6 @@ impl ConfigBuilder { None => HumanReadableNamesConfig::default(), }; - 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, @@ -484,6 +487,20 @@ impl ConfigBuilder { } } +impl EntropyConfig { + fn from_paths(mnemonic_file: Option, seed_file: Option) -> io::Result { + match (mnemonic_file, seed_file) { + (Some(_), Some(_)) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Only one of `node.entropy.mnemonic_file`/`--node-entropy-mnemonic-file` and `node.entropy.seed_file`/`--node-entropy-seed-file` may be configured.".to_string(), + )), + (Some(mnemonic_file), None) => Ok(Self::MnemonicFile(mnemonic_file)), + (None, Some(seed_file)) => Ok(Self::SeedFile(seed_file)), + (None, None) => Ok(Self::Default), + } + } +} + /// Configuration loaded from a TOML file. #[derive(Deserialize, Serialize)] pub struct TomlConfig { @@ -846,6 +863,20 @@ pub struct ArgsConfig { )] node_async_payments_role: Option, + #[arg( + long, + env = "LDK_SERVER_NODE_ENTROPY_MNEMONIC_FILE", + help = "Path to the BIP39 mnemonic file used for node entropy." + )] + node_entropy_mnemonic_file: Option, + + #[arg( + long, + env = "LDK_SERVER_NODE_ENTROPY_SEED_FILE", + help = "Legacy path to a raw 64-byte seed file used for node entropy." + )] + node_entropy_seed_file: Option, + #[arg( long, env = "LDK_SERVER_METRICS_ENABLED", @@ -903,10 +934,10 @@ pub fn load_config(args: &ArgsConfig) -> io::Result { ) })?; - builder.merge_toml(toml_config); + builder.merge_toml(toml_config)?; } - builder.merge_args(args); + builder.merge_args(args)?; builder.build() } @@ -946,8 +977,10 @@ fn parse_host_port(addr: &str) -> io::Result<(String, u16)> { #[cfg(test)] mod tests { + use std::ffi::OsString; use std::str::FromStr; + use clap::Parser; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Network; use ldk_node::lightning::ln::msgs::SocketAddress; @@ -1021,6 +1054,8 @@ mod tests { metrics_username: None, metrics_password: None, tor_proxy_address: None, + node_entropy_mnemonic_file: None, + node_entropy_seed_file: None, } } @@ -1043,6 +1078,8 @@ mod tests { metrics_username: None, metrics_password: None, tor_proxy_address: None, + node_entropy_mnemonic_file: None, + node_entropy_seed_file: None, } } @@ -1053,6 +1090,13 @@ mod tests { ) } + fn restore_env_var(key: &str, old_value: Option) { + match old_value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + #[test] fn test_config_from_file() { let storage_path = std::env::temp_dir(); @@ -1851,8 +1895,10 @@ mod tests { 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); + assert_eq!( + config.entropy, + EntropyConfig::MnemonicFile("/some/path/keys_mnemonic".to_string()) + ); } #[test] @@ -1884,4 +1930,62 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidInput); assert!(err.to_string().contains("Only one of")); } + + #[test] + fn test_node_entropy_args_override_file() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_node_entropy_args_override_file.toml"; + + let toml_config = r#" + [node] + network = "regtest" + grpc_service_address = "127.0.0.1:3002" + + [node.entropy] + mnemonic_file = "/config/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()); + args_config.node_entropy_seed_file = Some("/legacy/keys_seed".to_string()); + + let config = load_config(&args_config).unwrap(); + assert_eq!(config.entropy, EntropyConfig::SeedFile("/legacy/keys_seed".to_string())); + } + + #[test] + fn test_rejects_both_entropy_args() { + let mut args_config = default_args_config(); + args_config.node_entropy_mnemonic_file = Some("/some/path/keys_mnemonic".to_string()); + args_config.node_entropy_seed_file = Some("/some/path/keys_seed".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")); + } + + #[test] + fn test_node_entropy_mnemonic_file_env() { + let mnemonic_key = "LDK_SERVER_NODE_ENTROPY_MNEMONIC_FILE"; + let seed_key = "LDK_SERVER_NODE_ENTROPY_SEED_FILE"; + let old_mnemonic = std::env::var_os(mnemonic_key); + let old_seed = std::env::var_os(seed_key); + + std::env::set_var(mnemonic_key, "/env/keys_mnemonic"); + std::env::remove_var(seed_key); + let parse_result = ArgsConfig::try_parse_from(["ldk-server"]); + restore_env_var(mnemonic_key, old_mnemonic); + restore_env_var(seed_key, old_seed); + + let args_config = parse_result.unwrap(); + assert_eq!(args_config.node_entropy_mnemonic_file, Some("/env/keys_mnemonic".to_string())); + assert_eq!(args_config.node_entropy_seed_file, None); + } } diff --git a/ldk-server/src/util/entropy.rs b/ldk-server/src/util/entropy.rs index 6e062c33..8c3aa6c2 100644 --- a/ldk-server/src/util/entropy.rs +++ b/ldk-server/src/util/entropy.rs @@ -20,30 +20,20 @@ use log::info; use crate::util::config::EntropyConfig; const DEFAULT_MNEMONIC_FILE: &str = "keys_mnemonic"; +#[cfg(test)] 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_path = match entropy_config { + EntropyConfig::SeedFile(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())); + }, + EntropyConfig::MnemonicFile(path) => PathBuf::from(path), + EntropyConfig::Default => storage_dir.join(DEFAULT_MNEMONIC_FILE), }; let mnemonic = if mnemonic_path.exists() { @@ -72,9 +62,9 @@ pub(crate) fn load_or_generate_node_entropy( fn write_mnemonic_file(path: &Path, mnemonic: &Mnemonic) -> io::Result<()> { let mut f = fs::OpenOptions::new().create_new(true).write(true).mode(0o600).open(path)?; + f.set_permissions(fs::Permissions::from_mode(0o600))?; writeln!(f, "{}", mnemonic)?; f.sync_all()?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; Ok(()) } @@ -133,17 +123,14 @@ mod tests { } #[test] - fn auto_detects_legacy_keys_seed() { + fn default_entropy_ignores_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!(dir.join(DEFAULT_MNEMONIC_FILE).exists(), "keys_mnemonic was not created"); assert!(legacy_path.exists(), "legacy keys_seed was removed"); } @@ -153,10 +140,7 @@ mod tests { 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, - }; + let cfg = EntropyConfig::SeedFile(custom_seed.to_string_lossy().into_owned()); load_or_generate_node_entropy(&dir, &cfg).unwrap(); @@ -183,10 +167,7 @@ mod tests { 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, - }; + let cfg = EntropyConfig::MnemonicFile(custom_path.to_string_lossy().into_owned()); load_or_generate_node_entropy(&dir, &cfg).unwrap(); From 3dfad9965fb54182200865c8538382ebdc6e85e1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Apr 2026 15:13:43 +0200 Subject: [PATCH 3/4] Document mnemonic-based node entropy configuration Update the example config and operator-facing docs to reflect the new default of a BIP39 mnemonic at `/keys_mnemonic`, the mutually exclusive `[node.entropy]` options, and the legacy `keys_seed` backwards-compatibility path. Update the backup table to list both files so legacy operators don't lose track of their existing seed. Generated with the assistance of AI (Claude). Co-Authored-By: HAL 9000 --- contrib/ldk-server-config.toml | 9 +++++++++ docs/configuration.md | 23 +++++++++++++++++++---- docs/operations.md | 9 +++++---- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/contrib/ldk-server-config.toml b/contrib/ldk-server-config.toml index 82edb062..d0ad8ec6 100644 --- a/contrib/ldk-server-config.toml +++ b/contrib/ldk-server-config.toml @@ -9,6 +9,15 @@ alias = "ldk_server" # Lightning node alias #rgs_server_url = "https://rapidsync.lightningdevkit.org/snapshot/v2/" # Optional: RGS URL for rapid gossip sync #async_payments_role = "client" # Optional async payments role: "client" or "server" +# 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 5a0efe8a..34e210a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,7 +151,8 @@ Two resolution methods are supported via the `mode` field: ``` / - 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/ @@ -161,6 +162,20 @@ Two resolution methods are supported via the `mode` field: 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 28e7f4c6..50793f04 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 | @@ -195,6 +196,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. From bcb430a6068e5a9c6c77ce810792a495a3b6af7a Mon Sep 17 00:00:00 2001 From: tnull Date: Thu, 21 May 2026 12:32:42 +0200 Subject: [PATCH 4/4] f - Document explicit legacy entropy selection Review feedback removed the automatic legacy keys_seed fallback. Update the config docs and sample template so operators know an existing raw seed must be selected explicitly. Document the new CLI and environment names next to the TOML fields so non-file configuration is discoverable. Co-Authored-By: HAL 9000 --- contrib/ldk-server-config.toml | 9 ++++++--- docs/configuration.md | 9 ++++++--- docs/operations.md | 8 ++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/contrib/ldk-server-config.toml b/contrib/ldk-server-config.toml index d0ad8ec6..280b2953 100644 --- a/contrib/ldk-server-config.toml +++ b/contrib/ldk-server-config.toml @@ -11,11 +11,14 @@ alias = "ldk_server" # Lightning node alias # 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". +# Path to a BIP39 mnemonic file. If unset, a fresh 24-word mnemonic is generated on +# first start. Defaults to "/keys_mnemonic". +# CLI/env: --node-entropy-mnemonic-file / LDK_SERVER_NODE_ENTROPY_MNEMONIC_FILE #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`. +# BIP39 mnemonic support. Configure this explicitly to keep using an existing keys_seed file. +# CLI/env: --node-entropy-seed-file / LDK_SERVER_NODE_ENTROPY_SEED_FILE +# Mutually exclusive with `mnemonic_file`. #seed_file = "/tmp/ldk-server/keys_seed" # Storage settings diff --git a/docs/configuration.md b/docs/configuration.md index 34e210a5..670d4739 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,7 +152,7 @@ Two resolution methods are supported via the `mode` field: ``` / keys_mnemonic # BIP39 mnemonic (default for new installs) - keys_seed # Legacy raw seed (only present on installs initialized before mnemonic support) + keys_seed # Legacy raw seed (only used when explicitly configured) tls.crt # TLS certificate (PEM) tls.key # TLS private key (PEM) / # e.g., bitcoin/, regtest/, signet/ @@ -174,8 +174,11 @@ 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. + CLI/env: `--node-entropy-mnemonic-file` / `LDK_SERVER_NODE_ENTROPY_MNEMONIC_FILE`. - `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`. + CLI/env: `--node-entropy-seed-file` / `LDK_SERVER_NODE_ENTROPY_SEED_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. +Legacy raw-seed installs are not auto-detected. To keep using an existing +`/keys_seed`, set `seed_file` explicitly or use the corresponding CLI argument or +environment variable. diff --git a/docs/operations.md b/docs/operations.md index 50793f04..fd3e923a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -49,7 +49,7 @@ setup): | File | Priority | Description | | -------------------------------------- | ------------ | -------------------------------------------------------------------------- | | `/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. | +| `/keys_seed` | **Critical** | Legacy raw seed file. Only used when explicitly configured. | | `/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 | @@ -196,6 +196,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_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. +The `keys_mnemonic` file, or an explicitly configured legacy `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.