Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions contrib/ldk-server-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<storage_dir>/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
Expand Down
23 changes: 19 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ Two resolution methods are supported via the `mode` field:

```
<storage_dir>/
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)
<network>/ # e.g., bitcoin/, regtest/, signet/
Expand All @@ -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 `<storage_dir>/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 `<storage_dir>/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.
9 changes: 5 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ setup):

| File | Priority | Description |
| -------------------------------------- | ------------ | -------------------------------------------------------------------------- |
| `<storage_dir>/keys_seed` | **Critical** | Node identity and master secret. Required to recover on-chain funds. |
| `<storage_dir>/keys_mnemonic` | **Critical** | BIP39 mnemonic. Required to recover on-chain funds. Default for new installs. |
| `<storage_dir>/keys_seed` | **Critical** | Legacy raw seed file. Only present on installs initialized before mnemonic support. |
| `<network_dir>/ldk_node_data.sqlite` | **Critical** | Channel state and on-chain wallet data. Required to recover channel funds. |
| `<network_dir>/ldk_server_data.sqlite` | Nice-to-have | Payment and forwarding history |

Expand Down Expand Up @@ -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.
9 changes: 5 additions & 4 deletions ldk-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
},
};
Expand Down
95 changes: 95 additions & 0 deletions ldk-server/src/util/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ pub struct Config {
pub metrics_password: Option<String>,
pub tor_config: Option<TorConfig>,
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 `<storage_dir>/keys_mnemonic`. If a legacy raw-seed file
/// exists at `<storage_dir>/keys_seed`, it is used for backwards compatibility.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct EntropyConfig {
pub mnemonic_file: Option<String>,
pub seed_file: Option<String>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we disallow both being Some this would work better as an enum imo

pub enum EntropyConfig {
  Mnemonic(String),
  SeedFile(String),
  Default, // default mnemonic path in storage dir
}

}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -119,6 +131,7 @@ struct ConfigBuilder {
metrics_password: Option<String>,
tor_proxy_address: Option<String>,
hrn: Option<HrnTomlConfig>,
entropy: EntropyConfig,
}

impl ConfigBuilder {
Expand All @@ -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 =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use take() here and below because we immediately want to wipe the mnemonic_file/seed_file from memory after parsing, correct?

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 {
Expand Down Expand Up @@ -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,
Expand All @@ -454,6 +479,7 @@ impl ConfigBuilder {
metrics_password,
tor_config: tor_proxy_address.map(|proxy_address| TorConfig { proxy_address }),
hrn_config,
entropy: self.entropy,
})
}
}
Expand Down Expand Up @@ -484,6 +510,13 @@ struct NodeConfig {
pathfinding_scores_source_url: Option<String>,
async_payments_role: Option<String>,
rgs_server_url: Option<String>,
entropy: Option<NodeEntropyTomlConfig>,
}

#[derive(Deserialize, Serialize)]
struct NodeEntropyTomlConfig {
mnemonic_file: Option<String>,
seed_file: Option<String>,
}

#[derive(Deserialize, Serialize)]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
}
}
Loading