From 984b733546ca5b9945e0f2142ef1746923412a2f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 23 Apr 2026 23:20:33 -0300 Subject: [PATCH 1/3] feat: add environment variable support for nsec private key Add MOSTRO_NSEC_PRIVKEY environment variable to allow storing the Nostr private key separately from settings.toml. The variable can be set via real environment, ~/.mostro/.env (auto-loaded at startup), or systemd/Docker configuration. Changes: - Add dotenvy dependency for .env file loading - Implement load_env_file() to auto-load /.env at startup - Implement apply_nsec_env_override() to read MOSTRO_NSEC_PRIVKEY and override --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 34 ++++++++- docs/STARTUP_AND_CONFIG.md | 10 ++- src/config/util.rs | 150 ++++++++++++++++++++++++++++++++++++- src/config/wizard.rs | 97 +++++++++++++++++++++++- 6 files changed, 285 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dec752ca..b9bcab79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,6 +1740,7 @@ dependencies = [ "clearscreen", "dialoguer", "dirs", + "dotenvy", "easy-hasher", "fedimint-tonic-lnd", "lightning-invoice", diff --git a/Cargo.toml b/Cargo.toml index 7e266da6..5bb63f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ once_cell = "1.20.2" bitcoin = "0.32.5" dialoguer = "0.11" dirs = "6.0.0" +dotenvy = "0.15.7" clearscreen = "4.0.1" tonic = "0.14.2" prost = "0.14.1" diff --git a/README.md b/README.md index 7a2b2400..af268686 100644 --- a/README.md +++ b/README.md @@ -492,7 +492,8 @@ payment_retries_interval = 60 # seconds between retries #### Nostr Configuration ```toml [nostr] -# Your Mostro daemon's private key (nsec format) +# Your Mostro daemon's private key (nsec format). Optional if MOSTRO_NSEC_PRIVKEY +# is set via environment variable or ~/.mostro/.env (see below). nsec_privkey = 'nsec1...' # Relays to connect to @@ -511,6 +512,37 @@ rana --vanity mostro **Important**: Never reuse keys between Mostro instances. Each daemon needs a unique identity. +##### Providing the nsec via environment variable + +For better separation of secrets from config, Mostro can read the nsec from the +`MOSTRO_NSEC_PRIVKEY` environment variable. When set, it takes precedence over +`nsec_privkey` in `settings.toml`. + +Three common ways to provide it: + +1. **`~/.mostro/.env`** (auto-loaded at startup, `chmod 600` recommended): + ``` + MOSTRO_NSEC_PRIVKEY=nsec1... + ``` + The interactive setup wizard can create this file for you. + +2. **systemd service**: + ```ini + [Service] + Environment="MOSTRO_NSEC_PRIVKEY=nsec1..." + # or, better, with LoadCredential and a credential file: + # LoadCredential=mostro_nsec:/etc/mostro/nsec + ``` + +3. **Docker**: + ```bash + docker run -e MOSTRO_NSEC_PRIVKEY=nsec1... mostro + # or via Docker secrets / compose env_file + ``` + +Precedence is: real env var > `~/.mostro/.env` > `settings.toml`. Leaving +`nsec_privkey` in `settings.toml` is still supported for existing installations. + --- #### Mostro Business Logic diff --git a/docs/STARTUP_AND_CONFIG.md b/docs/STARTUP_AND_CONFIG.md index e614567c..81165c56 100644 --- a/docs/STARTUP_AND_CONFIG.md +++ b/docs/STARTUP_AND_CONFIG.md @@ -89,8 +89,14 @@ Configuration is loaded from `~/.mostro/settings.toml` (template: `settings.tpl. - Example (absolute path; use a real path — **do not** use `~`; SQLx does not expand tilde): `"sqlite:///home/youruser/.mostro/mostro.db"` - Default: `"sqlite://mostro.db"` -**Nostr** (`src/config/types.rs:47-54`): -- `nsec_privkey` (String): Mostro's Nostr private key in nsec format +**Nostr** (`src/config/types.rs`): +- `nsec_privkey` (String): Mostro's Nostr private key in nsec format. + - Can be overridden by the `MOSTRO_NSEC_PRIVKEY` environment variable + (env var takes precedence; whitespace-only values are ignored). + - Mostro also auto-loads `/.env` at startup (e.g. + `~/.mostro/.env`) so the variable can live in a separate file with + restricted permissions. + - Precedence: real env var > `/.env` > `settings.toml`. - `relays` (Vec): List of Nostr relay URLs for event broadcasting - Default: `['ws://localhost:7000']` - Note: At least one relay required diff --git a/src/config/util.rs b/src/config/util.rs index 75394177..efdb46fe 100644 --- a/src/config/util.rs +++ b/src/config/util.rs @@ -12,6 +12,30 @@ use std::io::IsTerminal; use std::path::PathBuf; const DB_FILENAME: &str = "mostro.db"; +const ENV_FILENAME: &str = ".env"; +const NSEC_ENV_VAR: &str = "MOSTRO_NSEC_PRIVKEY"; + +/// Loads the optional `/.env` file so that values placed there +/// become available through `std::env::var`. Variables already set in the +/// process environment take precedence and are never overwritten. +fn load_env_file(settings_dir: &std::path::Path) { + let env_file = settings_dir.join(ENV_FILENAME); + if env_file.exists() { + let _ = dotenvy::from_path(&env_file); + } +} + +/// If the `MOSTRO_NSEC_PRIVKEY` environment variable is set to a non-empty +/// value, override the nsec loaded from `settings.toml`. Whitespace is +/// trimmed; blank values are ignored so the TOML stays the fallback. +fn apply_nsec_env_override(settings: &mut Settings) { + if let Ok(nsec_from_env) = std::env::var(NSEC_ENV_VAR) { + let trimmed = nsec_from_env.trim(); + if !trimmed.is_empty() { + settings.nostr.nsec_privkey = trimmed.to_string(); + } + } +} /// Validates Mostro settings on startup fn validate_mostro_settings(settings: &Settings) -> Result<(), MostroError> { @@ -56,10 +80,15 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro std::fs::create_dir_all(&settings_dir) .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; } + + // Load `/.env` so MOSTRO_NSEC_PRIVKEY (and any future env + // overrides) can be read from it. Real env vars keep precedence. + load_env_file(&settings_dir); + let config_file_path = settings_dir.join("settings.toml"); if !config_file_path.exists() { - let settings = if std::io::stdin().is_terminal() { + let mut settings = if std::io::stdin().is_terminal() { // Interactive: show setup menu (wizard or manual template) wizard::run_setup_menu(&settings_dir, &config_file_path)? } else { @@ -73,6 +102,7 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro std::process::exit(0); }; + apply_nsec_env_override(&mut settings); validate_mostro_settings(&settings)?; init_mostro_settings(settings); tracing::info!("Settings correctly loaded!"); @@ -87,6 +117,10 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro let mut settings: Settings = toml::from_str(&contents) .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; + // Apply MOSTRO_NSEC_PRIVKEY override before validation so an empty TOML + // value is fine when the env var is set. + apply_nsec_env_override(&mut settings); + // Validate settings before initializing validate_mostro_settings(&settings)?; @@ -100,3 +134,117 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{ + DatabaseSettings, LightningSettings, MostroSettings, NostrSettings, RpcSettings, + }; + use std::sync::Mutex; + + // Tests that read/write MOSTRO_NSEC_PRIVKEY must run serially because the + // process environment is shared across threads. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// RAII guard that saves the current value of an env var and restores it + /// on drop, so tests don't leak state into each other. + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn new(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, previous } + } + + fn set(&self, value: &str) { + std::env::set_var(self.key, value); + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(val) => std::env::set_var(self.key, val), + None => std::env::remove_var(self.key), + } + } + } + + fn make_settings(nsec: &str) -> Settings { + Settings { + database: DatabaseSettings::default(), + lightning: LightningSettings::default(), + nostr: NostrSettings { + nsec_privkey: nsec.to_string(), + relays: vec!["wss://relay.test".to_string()], + }, + mostro: MostroSettings::default(), + rpc: RpcSettings::default(), + expiration: None, + } + } + + #[test] + fn env_var_overrides_toml_nsec() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(NSEC_ENV_VAR); + guard.set("nsec_from_env"); + + let mut settings = make_settings("nsec_from_toml"); + apply_nsec_env_override(&mut settings); + + assert_eq!(settings.nostr.nsec_privkey, "nsec_from_env"); + } + + #[test] + fn empty_env_var_falls_back_to_toml() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(NSEC_ENV_VAR); + guard.set(""); + + let mut settings = make_settings("nsec_from_toml"); + apply_nsec_env_override(&mut settings); + + assert_eq!(settings.nostr.nsec_privkey, "nsec_from_toml"); + } + + #[test] + fn no_env_var_keeps_toml() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvVarGuard::new(NSEC_ENV_VAR); + + let mut settings = make_settings("nsec_from_toml"); + apply_nsec_env_override(&mut settings); + + assert_eq!(settings.nostr.nsec_privkey, "nsec_from_toml"); + } + + #[test] + fn whitespace_only_env_is_ignored() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(NSEC_ENV_VAR); + guard.set(" \t "); + + let mut settings = make_settings("nsec_from_toml"); + apply_nsec_env_override(&mut settings); + + assert_eq!(settings.nostr.nsec_privkey, "nsec_from_toml"); + } + + #[test] + fn env_var_value_is_trimmed() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(NSEC_ENV_VAR); + guard.set(" nsec_from_env "); + + let mut settings = make_settings("nsec_from_toml"); + apply_nsec_env_override(&mut settings); + + assert_eq!(settings.nostr.nsec_privkey, "nsec_from_env"); + } +} diff --git a/src/config/wizard.rs b/src/config/wizard.rs index 5c4a6e45..f820b134 100644 --- a/src/config/wizard.rs +++ b/src/config/wizard.rs @@ -58,7 +58,7 @@ fn run_setup_wizard(settings_dir: &Path, config_file_path: &Path) -> Result Result { }) } -fn prompt_nostr_settings() -> Result { +fn prompt_nostr_settings(settings_dir: &Path) -> Result { let has_nsec = Confirm::new() .with_prompt("Do you have an existing nsec key?") .default(false) .interact() .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - let nsec_privkey = if has_nsec { + let nsec = if has_nsec { Input::new() .with_prompt("Enter your nsec private key") .validate_with(|input: &String| validate_nsec(input)) @@ -169,11 +169,12 @@ fn prompt_nostr_settings() -> Result { println!("\nGenerated new Nostr keypair:"); println!(" nsec: {}", nsec); println!(" npub: {}", npub); - println!("\n IMPORTANT: Save your nsec in a secure place. You will need it to recover your Mostro identity.\n"); nsec }; + let nsec_privkey = prompt_nsec_storage(settings_dir, &nsec)?; + let relays_input: String = Input::new() .with_prompt("Nostr relays (comma-separated)") .default("wss://relay.mostro.network".to_string()) @@ -193,6 +194,94 @@ fn prompt_nostr_settings() -> Result { }) } +/// Ask the user where to persist the nsec and return the value that should be +/// written into `settings.toml` (empty string when the key is stored elsewhere). +fn prompt_nsec_storage(settings_dir: &Path, nsec: &str) -> Result { + println!( + "\nStoring your nsec as an environment variable (instead of settings.toml) keeps" + ); + println!("secrets separate from config. This helps avoid accidental leaks in logs, backups"); + println!("or bug reports, and makes it easier to integrate with Docker secrets, systemd"); + println!("credentials, or a vault later. You can always move the key to another location"); + println!("afterwards — Mostro only requires MOSTRO_NSEC_PRIVKEY to be readable at startup.\n"); + + let env_file_path = settings_dir.join(".env"); + let choices = &[ + "Save to .env (recommended, auto-loaded at startup)", + "Save inline in settings.toml (legacy, still supported)", + "I'll configure MOSTRO_NSEC_PRIVKEY myself", + ]; + + let selection = Select::new() + .with_prompt("Where do you want to store your Nostr private key?") + .items(choices) + .default(0) + .interact() + .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; + + let nsec_in_toml = match selection { + 0 => { + write_env_file(&env_file_path, nsec)?; + println!( + "\n Private key saved to {} (permissions 600).", + env_file_path.display() + ); + String::new() + } + 1 => { + println!( + "\n Private key will be written inside {}.", + settings_dir.join("settings.toml").display() + ); + nsec.to_string() + } + _ => { + println!("\n Set MOSTRO_NSEC_PRIVKEY before starting Mostro, for example:"); + println!(" bash/zsh: export MOSTRO_NSEC_PRIVKEY={}", nsec); + println!(" systemd: Environment=\"MOSTRO_NSEC_PRIVKEY={}\"", nsec); + println!(" docker: -e MOSTRO_NSEC_PRIVKEY={}", nsec); + println!( + "\n Nothing will be written to {} or {}.", + env_file_path.display(), + settings_dir.join("settings.toml").display() + ); + String::new() + } + }; + + println!( + "\n IMPORTANT: Back up your nsec in a secure place. If you lose it, you lose control of this Mostro instance's identity.\n" + ); + + Ok(nsec_in_toml) +} + +/// Write `MOSTRO_NSEC_PRIVKEY=` to the given path with 0o600 permissions on Unix. +fn write_env_file(path: &Path, nsec: &str) -> Result<(), MostroError> { + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + }; + #[cfg(not(unix))] + let file = { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + }; + let mut file = file.map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; + writeln!(file, "MOSTRO_NSEC_PRIVKEY={}", nsec) + .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; + Ok(()) +} + fn prompt_mostro_settings() -> Result { let fee: f64 = Input::new() .with_prompt("Mostro fee (e.g. 0.01 = 1%)") From dd9e3e33be2f82580b70ba5bc33f9a5ffe198ac5 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 24 Apr 2026 00:03:41 -0300 Subject: [PATCH 2/3] feat: make nsec_privkey optional in settings.toml when using environment variable - Mark NostrSettings.nsec_privkey as optional with #[serde(default)] - Remove "I'll configure MOSTRO_NSEC_PRIVKEY myself" option from setup wizard - Export MOSTRO_NSEC_PRIVKEY to current process when saving to .env so daemon can use it immediately without restart - Add test verifying settings.toml parses without nsec_privkey field when relying on environment variable --- src/config/types.rs | 4 +++- src/config/util.rs | 11 +++++++++++ src/config/wizard.rs | 45 ++++++++++++++++---------------------------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/config/types.rs b/src/config/types.rs index 0d218829..c639ad7e 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -125,7 +125,9 @@ pub struct LightningSettings { /// Nostr configuration settings #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct NostrSettings { - /// Nostr private key + /// Nostr private key. Optional when `MOSTRO_NSEC_PRIVKEY` is provided via + /// environment variable or `/.env`. + #[serde(default)] pub nsec_privkey: String, /// Nostr relays list pub relays: Vec, diff --git a/src/config/util.rs b/src/config/util.rs index efdb46fe..acee2988 100644 --- a/src/config/util.rs +++ b/src/config/util.rs @@ -247,4 +247,15 @@ mod tests { assert_eq!(settings.nostr.nsec_privkey, "nsec_from_env"); } + + #[test] + fn toml_parses_without_nsec_privkey_field() { + // Operators who rely exclusively on MOSTRO_NSEC_PRIVKEY should be able + // to omit nsec_privkey from settings.toml entirely. + let toml_without_nsec = r#"relays = ["wss://relay.test"]"#; + let nostr: NostrSettings = + toml::from_str(toml_without_nsec).expect("nsec_privkey should be optional in TOML"); + assert_eq!(nostr.nsec_privkey, ""); + assert_eq!(nostr.relays, vec!["wss://relay.test"]); + } } diff --git a/src/config/wizard.rs b/src/config/wizard.rs index f820b134..a62234b6 100644 --- a/src/config/wizard.rs +++ b/src/config/wizard.rs @@ -209,7 +209,6 @@ fn prompt_nsec_storage(settings_dir: &Path, nsec: &str) -> Result Result { - write_env_file(&env_file_path, nsec)?; - println!( - "\n Private key saved to {} (permissions 600).", - env_file_path.display() - ); - String::new() - } - 1 => { - println!( - "\n Private key will be written inside {}.", - settings_dir.join("settings.toml").display() - ); - nsec.to_string() - } - _ => { - println!("\n Set MOSTRO_NSEC_PRIVKEY before starting Mostro, for example:"); - println!(" bash/zsh: export MOSTRO_NSEC_PRIVKEY={}", nsec); - println!(" systemd: Environment=\"MOSTRO_NSEC_PRIVKEY={}\"", nsec); - println!(" docker: -e MOSTRO_NSEC_PRIVKEY={}", nsec); - println!( - "\n Nothing will be written to {} or {}.", - env_file_path.display(), - settings_dir.join("settings.toml").display() - ); - String::new() - } + let nsec_in_toml = if selection == 0 { + write_env_file(&env_file_path, nsec)?; + // Export the key into the current process so the daemon can use it + // immediately after the wizard finishes, without requiring a restart. + std::env::set_var("MOSTRO_NSEC_PRIVKEY", nsec); + println!( + "\n Private key saved to {} (permissions 600).", + env_file_path.display() + ); + String::new() + } else { + println!( + "\n Private key will be written inside {}.", + settings_dir.join("settings.toml").display() + ); + nsec.to_string() }; println!( From b9565bf9aa98b1196193e5035de2ef07d016c88e Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 24 Apr 2026 00:09:13 -0300 Subject: [PATCH 3/3] fix: remove line break in nsec storage prompt message --- src/config/wizard.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/config/wizard.rs b/src/config/wizard.rs index a62234b6..f24f286e 100644 --- a/src/config/wizard.rs +++ b/src/config/wizard.rs @@ -197,9 +197,7 @@ fn prompt_nostr_settings(settings_dir: &Path) -> Result Result { - println!( - "\nStoring your nsec as an environment variable (instead of settings.toml) keeps" - ); + println!("\nStoring your nsec as an environment variable (instead of settings.toml) keeps"); println!("secrets separate from config. This helps avoid accidental leaks in logs, backups"); println!("or bug reports, and makes it easier to integrate with Docker secrets, systemd"); println!("credentials, or a vault later. You can always move the key to another location");