diff --git a/src/bin/fips.rs b/src/bin/fips.rs index d0ab35c..be28cc6 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -3,6 +3,7 @@ //! Loads configuration and creates the top-level node instance. use clap::Parser; +use fips::config::{resolve_identity, IdentitySource}; use fips::{Config, Node}; use std::path::PathBuf; use tracing::{error, info, warn, Level}; @@ -63,14 +64,24 @@ async fn main() { } } - // Log identity status - if config.has_identity() { - info!("Using configured identity"); - } else { - warn!("No identity configured, generating ephemeral keypair"); + // Identity provisioning: config nsec > key file > generate ephemeral + let resolved = match resolve_identity(&config, &loaded_paths) { + Ok(r) => r, + Err(e) => { + error!("Failed to resolve identity: {}", e); + std::process::exit(1); + } + }; + match &resolved.source { + IdentitySource::Config => info!("Using identity from configuration"), + IdentitySource::KeyFile(p) => info!(path = %p.display(), "Loaded persistent identity from key file"), + IdentitySource::Generated(p) => info!(path = %p.display(), "Generated persistent identity, saved to key file"), + IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"), } - // Create node + // Create node with resolved identity + let mut config = config; + config.node.identity.nsec = Some(resolved.nsec); info!("Creating node"); let mut node = match Node::new(config) { Ok(node) => node, diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 674b902..6fe90a2 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -4,6 +4,8 @@ //! query command, and pretty-prints the JSON response. use clap::{Parser, Subcommand}; +use fips::config::{write_key_file, write_pub_file}; +use fips::{encode_nsec, Identity}; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; @@ -28,6 +30,18 @@ enum Commands { #[command(subcommand)] what: ShowCommands, }, + /// Generate a new FIPS identity keypair + Keygen { + /// Output directory for fips.key and fips.pub + #[arg(short = 'd', long = "dir", default_value = "/etc/fips")] + dir: PathBuf, + /// Overwrite existing key files + #[arg(short = 'f', long = "force")] + force: bool, + /// Print nsec and npub to stdout instead of writing files + #[arg(short = 's', long = "stdout")] + stdout: bool, + }, } #[derive(Subcommand, Debug)] @@ -88,9 +102,61 @@ fn default_socket_path() -> PathBuf { fn main() { let cli = Cli::parse(); + // Commands that don't require a running daemon + match &cli.command { + Commands::Keygen { + dir, + force, + stdout, + } => { + let identity = Identity::generate(); + let nsec = encode_nsec(&identity.keypair().secret_key()); + let npub = identity.npub(); + + if *stdout { + println!("{}", nsec); + println!("{}", npub); + return; + } + + let key_path = dir.join("fips.key"); + let pub_path = dir.join("fips.pub"); + + if key_path.exists() && !force { + eprintln!("error: key file already exists: {}", key_path.display()); + eprintln!("Use --force to overwrite."); + std::process::exit(1); + } + + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!("error: cannot create directory {}: {}", dir.display(), e); + std::process::exit(1); + } + + if let Err(e) = write_key_file(&key_path, &nsec) { + eprintln!("error: failed to write key file: {}", e); + std::process::exit(1); + } + + if let Err(e) = write_pub_file(&pub_path, &npub) { + eprintln!("error: failed to write pub file: {}", e); + std::process::exit(1); + } + + eprintln!("{}", npub); + eprintln!("Key files written to: {}/", dir.display()); + eprintln!(); + eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml"); + eprintln!(" or these keys will be overwritten on next daemon start."); + return; + } + Commands::Show { .. } => {} + } + let socket_path = cli.socket.unwrap_or_else(default_socket_path); let command_name = match &cli.command { Commands::Show { what } => what.command_name(), + Commands::Keygen { .. } => unreachable!(), }; // Connect to the control socket diff --git a/src/config/mod.rs b/src/config/mod.rs index 936c51e..9abd200 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -38,6 +38,218 @@ pub use transport::{EthernetConfig, TcpConfig, TransportInstances, TransportsCon /// Default config filename. const CONFIG_FILENAME: &str = "fips.yaml"; +/// Default key filename, placed alongside the config file. +const KEY_FILENAME: &str = "fips.key"; + +/// Default public key filename, placed alongside the key file. +const PUB_FILENAME: &str = "fips.pub"; + +/// Derive the key file path from a config file path. +pub fn key_file_path(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or(Path::new(".")) + .join(KEY_FILENAME) +} + +/// Derive the public key file path from a config file path. +pub fn pub_file_path(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or(Path::new(".")) + .join(PUB_FILENAME) +} + +/// Read a bare bech32 nsec from a key file. +pub fn read_key_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile { + path: path.to_path_buf(), + source: e, + })?; + let nsec = contents.trim().to_string(); + if nsec.is_empty() { + return Err(ConfigError::EmptyKeyFile { + path: path.to_path_buf(), + }); + } + Ok(nsec) +} + +/// Write a bare bech32 nsec to a key file with restricted permissions (mode 0600). +pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + + file.write_all(nsec.as_bytes()) + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + file.write_all(b"\n") + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + Ok(()) +} + +/// Write a bare bech32 npub to a public key file (mode 0644). +pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(path) + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + + file.write_all(npub.as_bytes()) + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + file.write_all(b"\n") + .map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; + Ok(()) +} + +/// Resolve identity from config and key file. +/// +/// Behavior depends on `node.identity.persistent`: +/// +/// - **`persistent: false`** (default): generate a fresh ephemeral keypair +/// every start. Key files are written for operator visibility but overwritten +/// on each restart. +/// +/// - **`persistent: true`**: use three-tier resolution: +/// 1. Explicit nsec in config — highest priority +/// 2. Persistent key file (`fips.key`) — reused across restarts +/// 3. Generate new — creates keypair, writes `fips.key` and `fips.pub` +/// +/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`. +/// +/// Returns the nsec string (bech32 or hex) to be used for identity creation. +pub fn resolve_identity( + config: &Config, + loaded_paths: &[PathBuf], +) -> Result { + use crate::encode_nsec; + + // Explicit nsec in config always wins + if let Some(nsec) = &config.node.identity.nsec { + return Ok(ResolvedIdentity { + nsec: nsec.clone(), + source: IdentitySource::Config, + }); + } + + // Determine key file directory from loaded config paths + let config_ref = if let Some(path) = loaded_paths.last() { + path.clone() + } else { + Config::search_paths() + .first() + .cloned() + .unwrap_or_else(|| PathBuf::from("./fips.yaml")) + }; + let key_path = key_file_path(&config_ref); + let pub_path = pub_file_path(&config_ref); + + if config.node.identity.persistent { + // Persistent mode: load existing key file or generate-and-persist + if key_path.exists() { + let nsec = read_key_file(&key_path)?; + let identity = Identity::from_secret_str(&nsec)?; + let _ = write_pub_file(&pub_path, &identity.npub()); + return Ok(ResolvedIdentity { + nsec, + source: IdentitySource::KeyFile(key_path), + }); + } + + // No key file yet — generate and persist + let identity = Identity::generate(); + let nsec = encode_nsec(&identity.keypair().secret_key()); + let npub = identity.npub(); + + if let Some(parent) = key_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + match write_key_file(&key_path, &nsec) { + Ok(()) => { + let _ = write_pub_file(&pub_path, &npub); + Ok(ResolvedIdentity { + nsec, + source: IdentitySource::Generated(key_path), + }) + } + Err(_) => Ok(ResolvedIdentity { + nsec, + source: IdentitySource::Ephemeral, + }), + } + } else { + // Ephemeral mode (default): fresh keypair every start, write key files + // for operator visibility + let identity = Identity::generate(); + let nsec = encode_nsec(&identity.keypair().secret_key()); + let npub = identity.npub(); + + if let Some(parent) = key_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let _ = write_key_file(&key_path, &nsec); + let _ = write_pub_file(&pub_path, &npub); + + Ok(ResolvedIdentity { + nsec, + source: IdentitySource::Ephemeral, + }) + } +} + +/// Result of identity resolution. +pub struct ResolvedIdentity { + /// The nsec string (bech32 or hex) for creating an Identity. + pub nsec: String, + /// Where the identity came from. + pub source: IdentitySource, +} + +/// Where a resolved identity originated. +pub enum IdentitySource { + /// From explicit nsec in config file. + Config, + /// Loaded from a persistent key file. + KeyFile(PathBuf), + /// Generated and saved to a new key file. + Generated(PathBuf), + /// Generated but could not be persisted. + Ephemeral, +} + /// Errors that can occur during configuration loading. #[derive(Debug, Error)] pub enum ConfigError { @@ -53,6 +265,15 @@ pub enum ConfigError { source: serde_yaml::Error, }, + #[error("key file is empty: {path}")] + EmptyKeyFile { path: PathBuf }, + + #[error("failed to write key file {path}: {source}")] + WriteKeyFile { + path: PathBuf, + source: std::io::Error, + }, + #[error("identity error: {0}")] Identity(#[from] IdentityError), } @@ -64,6 +285,12 @@ pub struct IdentityConfig { /// If not specified, a new keypair will be generated. #[serde(default, skip_serializing_if = "Option::is_none")] pub nsec: Option, + + /// Whether to persist the identity across restarts (`node.identity.persistent`). + /// When false (default), a fresh ephemeral keypair is generated each start. + /// When true, the key file is reused across restarts. + #[serde(default)] + pub persistent: bool, } /// Root configuration structure. @@ -172,6 +399,9 @@ impl Config { if other.node.identity.nsec.is_some() { self.node.identity.nsec = other.node.identity.nsec; } + if other.node.identity.persistent { + self.node.identity.persistent = true; + } // Merge node.leaf_only if other.node.leaf_only { self.node.leaf_only = true; @@ -446,6 +676,179 @@ node: assert!(yaml.contains("test_nsec")); } + #[test] + fn test_key_file_write_read_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("fips.key"); + + let identity = crate::Identity::generate(); + let nsec = crate::encode_nsec(&identity.keypair().secret_key()); + + write_key_file(&key_path, &nsec).unwrap(); + + let loaded_nsec = read_key_file(&key_path).unwrap(); + assert_eq!(loaded_nsec, nsec); + + // Verify the loaded nsec produces the same identity + let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap(); + assert_eq!(loaded_identity.npub(), identity.npub()); + } + + #[test] + fn test_key_file_permissions() { + use std::os::unix::fs::MetadataExt; + + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("fips.key"); + + write_key_file(&key_path, "nsec1test").unwrap(); + + let metadata = fs::metadata(&key_path).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + + #[test] + fn test_pub_file_permissions() { + use std::os::unix::fs::MetadataExt; + + let temp_dir = TempDir::new().unwrap(); + let pub_path = temp_dir.path().join("fips.pub"); + + write_pub_file(&pub_path, "npub1test").unwrap(); + + let metadata = fs::metadata(&pub_path).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o644); + } + + #[test] + fn test_key_file_empty_error() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("fips.key"); + + fs::write(&key_path, "").unwrap(); + + let result = read_key_file(&key_path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn test_key_file_whitespace_trimmed() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("fips.key"); + + fs::write(&key_path, " nsec1test \n").unwrap(); + + let nsec = read_key_file(&key_path).unwrap(); + assert_eq!(nsec, "nsec1test"); + } + + #[test] + fn test_key_file_path_derivation() { + let config_path = PathBuf::from("/etc/fips/fips.yaml"); + assert_eq!(key_file_path(&config_path), PathBuf::from("/etc/fips/fips.key")); + assert_eq!(pub_file_path(&config_path), PathBuf::from("/etc/fips/fips.pub")); + } + + #[test] + fn test_resolve_identity_from_config() { + let mut config = Config::new(); + config.node.identity.nsec = Some( + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(), + ); + + let resolved = resolve_identity(&config, &[]).unwrap(); + assert!(matches!(resolved.source, IdentitySource::Config)); + } + + #[test] + fn test_resolve_identity_ephemeral_by_default() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + + fs::write(&config_path, "node:\n identity: {}\n").unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + assert!(!config.node.identity.persistent); + + let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + assert!(matches!(resolved.source, IdentitySource::Ephemeral)); + + // Key files should still be written for operator visibility + let key_path = temp_dir.path().join("fips.key"); + let pub_path = temp_dir.path().join("fips.pub"); + assert!(key_path.exists()); + assert!(pub_path.exists()); + } + + #[test] + fn test_resolve_identity_ephemeral_changes_each_call() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + + fs::write(&config_path, "node:\n identity: {}\n").unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + + // Each call generates a different key + assert_ne!(first.nsec, second.nsec); + } + + #[test] + fn test_resolve_identity_persistent_from_key_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + let key_path = temp_dir.path().join("fips.key"); + + fs::write( + &config_path, + "node:\n identity:\n persistent: true\n", + ) + .unwrap(); + + // Write a key file + let identity = crate::Identity::generate(); + let nsec = crate::encode_nsec(&identity.keypair().secret_key()); + write_key_file(&key_path, &nsec).unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + assert!(config.node.identity.persistent); + + let resolved = resolve_identity(&config, &[config_path]).unwrap(); + assert!(matches!(resolved.source, IdentitySource::KeyFile(_))); + assert_eq!(resolved.nsec, nsec); + } + + #[test] + fn test_resolve_identity_persistent_generates_and_persists() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + + fs::write( + &config_path, + "node:\n identity:\n persistent: true\n", + ) + .unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + + assert!(matches!(resolved.source, IdentitySource::Generated(_))); + + // Key file and pub file should now exist + let key_path = temp_dir.path().join("fips.key"); + let pub_path = temp_dir.path().join("fips.pub"); + assert!(key_path.exists()); + assert!(pub_path.exists()); + + // Second resolve should load from key file (not generate new) + let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + assert!(matches!(resolved2.source, IdentitySource::KeyFile(_))); + assert_eq!(resolved.nsec, resolved2.nsec); + } + #[test] fn test_to_yaml_empty_nsec_omitted() { let config = Config::new();