Skip to content
Merged
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: 5 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Two resolution methods are supported via the `mode` field:

```
<storage_dir>/
keys_seed # Node entropy/seed
keys_mnemonic # BIP39 mnemonic (default for new installs)
tls.crt # TLS certificate (PEM)
tls.key # TLS private key (PEM)
<network>/ # e.g., bitcoin/, regtest/, signet/
Expand All @@ -161,6 +161,7 @@ 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 is the node's master secret, required to recover on-chain funds. On first start,
ldk-server generates a fresh 24-word BIP39 mnemonic at `<storage_dir>/keys_mnemonic` if the file
does not already exist. `ldk_node_data.sqlite` holds channel state, both are required to recover
channel funds. See [Operations - Backups](operations.md#backups) for backup guidance.
6 changes: 3 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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. |
| `<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 +195,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
The `keys_mnemonic` 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 mnemonic will produce
different keys.
5 changes: 3 additions & 2 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,8 +1126,9 @@ async fn test_forwarded_payment_event() {
let b_addr = SocketAddress::from_str(&format!("127.0.0.1:{}", server_b.p2p_port)).unwrap();
builder_c.set_liquidity_source_lsps2(b_node_id, b_addr, None);

let seed_path_c = storage_dir_c.join("keys_seed").to_str().unwrap().to_string();
let node_entropy_c = ldk_node::entropy::NodeEntropy::from_seed_path(seed_path_c).unwrap();
let mnemonic_c = ldk_node::entropy::generate_entropy_mnemonic(None);
let node_entropy_c =
ldk_node::entropy::NodeEntropy::from_bip39_mnemonic(mnemonic_c, None);
let node_c = builder_c.build(node_entropy_c).unwrap();

node_c.start().unwrap();
Expand Down
6 changes: 2 additions & 4 deletions ldk-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::events::ClosureReason;
use ldk_node::lightning::ln::channelmanager::PaymentId;
use ldk_node::lightning::ln::types::ChannelId;
Expand Down Expand Up @@ -213,11 +212,10 @@ 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) {
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
76 changes: 76 additions & 0 deletions ldk-server/src/util/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ fn parse_host_port(addr: &str) -> io::Result<(String, u16)> {
mod tests {
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;
Expand Down Expand Up @@ -1861,4 +1862,79 @@ mod tests {
);
assert!(parse_dns_server_address("invalid@address").is_err());
}

#[test]
fn test_rejects_node_entropy_config() {
let storage_path = std::env::temp_dir();
let config_file_name = "test_rejects_node_entropy_config.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 err = load_config(&args_config).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(err.to_string().contains("unknown field `entropy`"));
}

#[test]
fn test_rejects_seed_file_config() {
let storage_path = std::env::temp_dir();
let config_file_name = "test_rejects_seed_file_config.toml";

let toml_config = r#"
[node]
network = "regtest"
grpc_service_address = "127.0.0.1:3002"

[node.entropy]
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::InvalidData);
assert!(err.to_string().contains("unknown field `entropy`"));
}

#[test]
fn test_rejects_node_entropy_args() {
let mnemonic_result = ArgsConfig::try_parse_from([
"ldk-server",
"--node-entropy-mnemonic-file",
"/some/path/keys_mnemonic",
]);
let seed_result = ArgsConfig::try_parse_from([
"ldk-server",
"--node-entropy-seed-file",
"/old/keys_seed",
]);

assert!(mnemonic_result.is_err());
assert!(seed_result.is_err());
}
}
129 changes: 129 additions & 0 deletions ldk-server/src/util/entropy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// This file is Copyright its original authors, visible in version control
// history.
//
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, 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;
use std::path::Path;
use std::str::FromStr;

use ldk_node::bip39::Mnemonic;
use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy};
use log::info;

use crate::util::write_new;

const DEFAULT_MNEMONIC_FILE: &str = "keys_mnemonic";

pub(crate) fn load_or_generate_node_entropy(storage_dir: &Path) -> io::Result<NodeEntropy> {
let mnemonic_path = 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_new(&mnemonic_path, format!("{}\n", mnemonic).as_bytes(), 0o600)?;
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))
}

#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::PathBuf;

const STALE_SEED_FILE: &str = "keys_seed";
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");

load_or_generate_node_entropy(&dir).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).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).unwrap();

let bytes_after = fs::read(&mnemonic_path).unwrap();
assert_eq!(bytes_before, bytes_after, "mnemonic file content changed");
}

#[test]
fn default_entropy_ignores_stale_keys_seed() {
let dir = tempdir("stale-seed");
let stale_seed_path = dir.join(STALE_SEED_FILE);
fs::write(&stale_seed_path, vec![0x42u8; 64]).unwrap();

load_or_generate_node_entropy(&dir).unwrap();

assert!(dir.join(DEFAULT_MNEMONIC_FILE).exists(), "keys_mnemonic was not created");
assert!(stale_seed_path.exists(), "stale keys_seed was removed");
}

#[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).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}
1 change: 1 addition & 0 deletions ldk-server/src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down