From b80b3fbecf1955278c8afb9b2e8f97771a89daba Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 25 Jan 2026 01:08:02 +0000 Subject: [PATCH] Add YAML configuration system and nsec encoding Configuration system: - New config module with cascading file search - Priority: ./fips.yaml > ~/.config/fips/ > ~/.fips.yaml > /etc/fips/ - Identity section with optional nsec (bech32 or hex format) - Generate new keypair if nsec not configured Identity module additions: - encode_nsec() and decode_nsec() for NIP-19 bech32 format - decode_secret() accepts both nsec and hex formats - Identity::from_secret_str() constructor Dependencies: serde, serde_yaml, dirs, hex 41 tests passing (20 identity + 15 config + 6 nsec) --- Cargo.lock | 245 +++++++++++++++++++++++++++++++- Cargo.toml | 6 +- src/config.rs | 366 ++++++++++++++++++++++++++++++++++++++++++++++++ src/identity.rs | 141 +++++++++++++++++++ src/lib.rs | 6 +- src/main.rs | 141 +++++++------------ 6 files changed, 809 insertions(+), 96 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index ee01ee5..e289966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -84,6 +90,49 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -95,10 +144,14 @@ name = "fips" version = "0.1.0" dependencies = [ "bech32", + "dirs", "hex", "rand", "secp256k1", + "serde", + "serde_yaml", "sha2", + "tempfile", "thiserror", ] @@ -123,6 +176,24 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hex" version = "0.4.3" @@ -138,12 +209,56 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -171,6 +286,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -198,9 +319,39 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "secp256k1" version = "0.30.0" @@ -221,6 +372,49 @@ dependencies = [ "cc", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -249,6 +443,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -281,6 +488,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "version_check" version = "0.9.5" @@ -293,6 +506,36 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zerocopy" version = "0.8.33" diff --git a/Cargo.toml b/Cargo.toml index 1338c12..a16ac31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,10 @@ sha2 = "0.10" rand = "0.8" thiserror = "2.0" bech32 = "0.11" +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +dirs = "6.0" +hex = "0.4" [dev-dependencies] -hex = "0.4" +tempfile = "3.15" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e320c13 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,366 @@ +//! FIPS Configuration System +//! +//! Loads configuration from YAML files with a cascading priority system: +//! 1. `./fips.yaml` (current directory - highest priority) +//! 2. `~/.config/fips/fips.yaml` (user config directory) +//! 3. `/etc/fips/fips.yaml` (system - lowest priority) +//! +//! Values from higher priority files override those from lower priority files. + +use crate::{Identity, IdentityError}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Default config filename. +const CONFIG_FILENAME: &str = "fips.yaml"; + +/// Errors that can occur during configuration loading. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file {path}: {source}")] + ReadFile { + path: PathBuf, + source: std::io::Error, + }, + + #[error("failed to parse config file {path}: {source}")] + ParseYaml { + path: PathBuf, + source: serde_yaml::Error, + }, + + #[error("identity error: {0}")] + Identity(#[from] IdentityError), +} + +/// Identity configuration section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct IdentityConfig { + /// Node secret key in nsec (bech32) or hex format. + /// If not specified, a new keypair will be generated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nsec: Option, +} + +/// Root configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + /// Identity configuration. + #[serde(default)] + pub identity: IdentityConfig, +} + +impl Config { + /// Create a new empty configuration. + pub fn new() -> Self { + Self::default() + } + + /// Load configuration from the standard search paths. + /// + /// Files are loaded in reverse priority order and merged: + /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority) + /// 2. `~/.config/fips/fips.yaml` (user config) + /// 3. `./fips.yaml` (loaded last, highest priority) + /// + /// Returns a tuple of (config, paths_loaded) where paths_loaded contains + /// the paths that were successfully loaded. + pub fn load() -> Result<(Self, Vec), ConfigError> { + let search_paths = Self::search_paths(); + Self::load_from_paths(&search_paths) + } + + /// Load configuration from specific paths. + /// + /// Paths are processed in order, with later paths overriding earlier ones. + pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec), ConfigError> { + let mut config = Config::default(); + let mut loaded_paths = Vec::new(); + + for path in paths { + if path.exists() { + let file_config = Self::load_file(path)?; + config.merge(file_config); + loaded_paths.push(path.clone()); + } + } + + Ok((config, loaded_paths)) + } + + /// Load configuration from a single file. + pub fn load_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile { + path: path.to_path_buf(), + source: e, + })?; + + serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml { + path: path.to_path_buf(), + source: e, + }) + } + + /// Get the standard search paths in priority order (lowest to highest). + pub fn search_paths() -> Vec { + let mut paths = Vec::new(); + + // System config (lowest priority) + paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME)); + + // User config directory + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("fips").join(CONFIG_FILENAME)); + } + + // Home directory (legacy location) + if let Some(home_dir) = dirs::home_dir() { + paths.push(home_dir.join(".fips.yaml")); + } + + // Current directory (highest priority) + paths.push(PathBuf::from(".").join(CONFIG_FILENAME)); + + paths + } + + /// Merge another configuration into this one. + /// + /// Values from `other` override values in `self` when present. + pub fn merge(&mut self, other: Config) { + // Merge identity section + if other.identity.nsec.is_some() { + self.identity.nsec = other.identity.nsec; + } + } + + /// Create an Identity from this configuration. + /// + /// If an nsec is configured, uses that to create the identity. + /// Otherwise, generates a new random identity. + pub fn create_identity(&self) -> Result { + match &self.identity.nsec { + Some(nsec) => Ok(Identity::from_secret_str(nsec)?), + None => Ok(Identity::generate()), + } + } + + /// Check if an identity is configured (vs. will be generated). + pub fn has_identity(&self) -> bool { + self.identity.nsec.is_some() + } + + /// Serialize this configuration to YAML. + pub fn to_yaml(&self) -> Result { + serde_yaml::to_string(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_empty_config() { + let config = Config::new(); + assert!(config.identity.nsec.is_none()); + assert!(!config.has_identity()); + } + + #[test] + fn test_parse_yaml_with_nsec() { + let yaml = r#" +identity: + nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.identity.nsec.is_some()); + assert!(config.has_identity()); + } + + #[test] + fn test_parse_yaml_with_hex() { + let yaml = r#" +identity: + nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.identity.nsec.is_some()); + + let identity = config.create_identity().unwrap(); + assert!(!identity.npub().is_empty()); + } + + #[test] + fn test_parse_yaml_empty() { + let yaml = ""; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.identity.nsec.is_none()); + } + + #[test] + fn test_parse_yaml_partial() { + let yaml = r#" +identity: {} +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.identity.nsec.is_none()); + } + + #[test] + fn test_merge_configs() { + let mut base = Config::new(); + base.identity.nsec = Some("base_nsec".to_string()); + + let override_config = Config { + identity: IdentityConfig { + nsec: Some("override_nsec".to_string()), + }, + }; + + base.merge(override_config); + assert_eq!(base.identity.nsec, Some("override_nsec".to_string())); + } + + #[test] + fn test_merge_preserves_base_when_override_empty() { + let mut base = Config::new(); + base.identity.nsec = Some("base_nsec".to_string()); + + let override_config = Config::new(); + + base.merge(override_config); + assert_eq!(base.identity.nsec, Some("base_nsec".to_string())); + } + + #[test] + fn test_create_identity_from_nsec() { + let config = Config { + identity: IdentityConfig { + nsec: Some( + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(), + ), + }, + }; + + let identity = config.create_identity().unwrap(); + assert!(!identity.npub().is_empty()); + } + + #[test] + fn test_create_identity_generates_new() { + let config = Config::new(); + let identity = config.create_identity().unwrap(); + assert!(!identity.npub().is_empty()); + } + + #[test] + fn test_load_from_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + + let yaml = r#" +identity: + nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +"#; + fs::write(&config_path, yaml).unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + assert!(config.identity.nsec.is_some()); + } + + #[test] + fn test_load_from_paths_merges() { + let temp_dir = TempDir::new().unwrap(); + + // Create two config files + let low_priority = temp_dir.path().join("low.yaml"); + let high_priority = temp_dir.path().join("high.yaml"); + + fs::write( + &low_priority, + r#" +identity: + nsec: "low_priority_nsec" +"#, + ) + .unwrap(); + + fs::write( + &high_priority, + r#" +identity: + nsec: "high_priority_nsec" +"#, + ) + .unwrap(); + + let paths = vec![low_priority.clone(), high_priority.clone()]; + let (config, loaded) = Config::load_from_paths(&paths).unwrap(); + + assert_eq!(loaded.len(), 2); + assert_eq!(config.identity.nsec, Some("high_priority_nsec".to_string())); + } + + #[test] + fn test_load_skips_missing_files() { + let temp_dir = TempDir::new().unwrap(); + let existing = temp_dir.path().join("exists.yaml"); + let missing = temp_dir.path().join("missing.yaml"); + + fs::write( + &existing, + r#" +identity: + nsec: "existing_nsec" +"#, + ) + .unwrap(); + + let paths = vec![missing, existing.clone()]; + let (config, loaded) = Config::load_from_paths(&paths).unwrap(); + + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0], existing); + assert_eq!(config.identity.nsec, Some("existing_nsec".to_string())); + } + + #[test] + fn test_search_paths_includes_expected() { + let paths = Config::search_paths(); + + // Should include current directory + assert!(paths.iter().any(|p| p.ends_with("fips.yaml"))); + + // Should include /etc/fips + assert!(paths + .iter() + .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))); + } + + #[test] + fn test_to_yaml() { + let config = Config { + identity: IdentityConfig { + nsec: Some("test_nsec".to_string()), + }, + }; + + let yaml = config.to_yaml().unwrap(); + assert!(yaml.contains("identity:")); + assert!(yaml.contains("nsec:")); + assert!(yaml.contains("test_nsec")); + } + + #[test] + fn test_to_yaml_empty_nsec_omitted() { + let config = Config::new(); + let yaml = config.to_yaml().unwrap(); + + // Empty nsec should not be serialized + assert!(!yaml.contains("nsec:")); + } +} diff --git a/src/identity.rs b/src/identity.rs index edaa958..0d44cb5 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -15,6 +15,9 @@ use thiserror::Error; /// Human-readable part for npub (NIP-19). const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub"); +/// Human-readable part for nsec (NIP-19). +const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec"); + /// Domain separation string for authentication challenges. const AUTH_DOMAIN: &[u8] = b"fips-auth-v1"; @@ -50,6 +53,15 @@ pub enum IdentityError { #[error("invalid npub: expected 32 bytes, got {0}")] InvalidNpubLength(usize), + + #[error("invalid nsec: expected 'nsec' prefix, got '{0}'")] + InvalidNsecPrefix(String), + + #[error("invalid nsec: expected 32 bytes, got {0}")] + InvalidNsecLength(usize), + + #[error("invalid hex encoding: {0}")] + InvalidHex(#[from] hex::FromHexError), } /// 32-byte node identifier derived from SHA-256(npub). @@ -296,6 +308,12 @@ impl Identity { Ok(Self::from_secret_key(secret_key)) } + /// Create an identity from an nsec string (bech32) or hex-encoded secret. + pub fn from_secret_str(s: &str) -> Result { + let secret_key = decode_secret(s)?; + Ok(Self::from_secret_key(secret_key)) + } + /// Return the x-only public key. pub fn pubkey(&self) -> XOnlyPublicKey { self.keypair.x_only_public_key().0 @@ -440,6 +458,42 @@ pub fn decode_npub(npub: &str) -> Result { Ok(pubkey) } +/// Encode a secret key as a bech32 nsec string (NIP-19). +pub fn encode_nsec(secret_key: &SecretKey) -> String { + bech32::encode::(NSEC_HRP, &secret_key.secret_bytes()) + .expect("nsec encoding cannot fail") +} + +/// Decode an nsec string to a secret key. +pub fn decode_nsec(nsec: &str) -> Result { + let (hrp, data) = bech32::decode(nsec)?; + + if hrp != NSEC_HRP { + return Err(IdentityError::InvalidNsecPrefix(hrp.to_string())); + } + + if data.len() != 32 { + return Err(IdentityError::InvalidNsecLength(data.len())); + } + + let secret_key = SecretKey::from_slice(&data)?; + Ok(secret_key) +} + +/// Decode a secret key from either nsec (bech32) or hex format. +pub fn decode_secret(s: &str) -> Result { + if s.starts_with("nsec1") { + decode_nsec(s) + } else { + let bytes = hex::decode(s)?; + if bytes.len() != 32 { + return Err(IdentityError::InvalidNsecLength(bytes.len())); + } + let secret_key = SecretKey::from_slice(&bytes)?; + Ok(secret_key) + } +} + #[cfg(test)] mod tests { use super::*; @@ -708,4 +762,91 @@ mod tests { assert!(display.starts_with("npub1")); assert_eq!(display, identity.npub()); } + + #[test] + fn test_nsec_roundtrip() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + assert!(nsec.starts_with("nsec1")); + assert_eq!(nsec.len(), 63); + + let decoded = decode_nsec(&nsec).unwrap(); + assert_eq!(decoded.secret_bytes(), secret_bytes); + } + + #[test] + fn test_decode_nsec_invalid_prefix() { + // Use a valid npub (from a generated identity) to test prefix rejection + let identity = Identity::generate(); + let npub = identity.npub(); + let result = decode_nsec(&npub); + assert!(matches!(result, Err(IdentityError::InvalidNsecPrefix(_)))); + } + + #[test] + fn test_decode_secret_nsec() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + let decoded = decode_secret(&nsec).unwrap(); + assert_eq!(decoded.secret_bytes(), secret_bytes); + } + + #[test] + fn test_decode_secret_hex() { + let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let decoded = decode_secret(hex_str).unwrap(); + + let expected: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + assert_eq!(decoded.secret_bytes(), expected); + } + + #[test] + fn test_identity_from_secret_str_nsec() { + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let secret_key = SecretKey::from_slice(&secret_bytes).unwrap(); + let nsec = encode_nsec(&secret_key); + + let identity = Identity::from_secret_str(&nsec).unwrap(); + let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); + + assert_eq!(identity.node_id(), identity_from_bytes.node_id()); + } + + #[test] + fn test_identity_from_secret_str_hex() { + let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let secret_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let identity = Identity::from_secret_str(hex_str).unwrap(); + let identity_from_bytes = Identity::from_secret_bytes(&secret_bytes).unwrap(); + + assert_eq!(identity.node_id(), identity_from_bytes.node_id()); + } } diff --git a/src/lib.rs b/src/lib.rs index 8201866..328ff93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,11 @@ //! A distributed, decentralized network routing protocol for mesh nodes //! connecting over arbitrary transports. +pub mod config; pub mod identity; +pub use config::{Config, ConfigError, IdentityConfig}; pub use identity::{ - decode_npub, encode_npub, AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, - NodeId, PeerIdentity, + decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec, AuthChallenge, AuthResponse, + FipsAddress, Identity, IdentityError, NodeId, PeerIdentity, }; diff --git a/src/main.rs b/src/main.rs index 214df7f..3885376 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,104 +1,61 @@ -use fips::{AuthChallenge, Identity, PeerIdentity}; +use fips::Config; fn main() { - println!("FIPS Identity Module Demo"); - println!("=========================\n"); + println!("FIPS Node Startup"); + println!("=================\n"); - // Generate a new identity - println!("1. Generating a new identity..."); - let alice = Identity::generate(); - println!(" npub: {}", alice.npub()); - println!(" node_id: {}", alice.node_id()); - println!(" address: {}", alice.address()); - - // Create a peer identity from an npub - println!("\n2. Creating PeerIdentity from npub..."); - let alice_peer = PeerIdentity::from_npub(&alice.npub()).unwrap(); - println!(" Parsed: {}", alice_peer); - println!(" Match: {}", alice_peer.node_id() == alice.node_id()); - - // Sign and verify data - println!("\n3. Signing and verifying data..."); - let message = b"Hello, FIPS network!"; - let signature = alice.sign(message); - println!(" Message: {:?}", String::from_utf8_lossy(message)); - println!(" Signed by Alice"); - - let valid = alice_peer.verify(message, &signature); - println!(" Verified by peer: {}", valid); - - let tampered = alice_peer.verify(b"Tampered message", &signature); - println!(" Tampered message: {}", tampered); - - // Authentication challenge-response - // This simulates the mutual authentication that occurs when two FIPS nodes - // establish a connection. Unlike TLS which binds identity at the transport - // layer, FIPS authentication works over any transport (including radio/serial). - println!("\n4. Authentication challenge-response..."); - println!(" Scenario: Alice wants to verify that Bob controls his claimed npub"); + // Load configuration from standard search paths + println!("1. Loading configuration..."); + println!(" Search paths (in priority order, lowest to highest):"); + for path in Config::search_paths() { + let exists = path.exists(); + let status = if exists { "[found]" } else { "[not found]" }; + println!(" {} {}", status, path.display()); + } println!(); - let bob = Identity::generate(); - println!(" Bob claims to be: {}", bob.npub()); - println!(" (Bob's node_id would be: {})", bob.node_id()); - println!(); - - // Step 1: Alice generates a random 32-byte challenge - // This nonce ensures Bob can't pre-compute responses - let challenge = AuthChallenge::generate(); - println!(" [Alice] Generated 32-byte random challenge"); - println!(" Challenge: {:02x}{:02x}{:02x}{:02x}...", - challenge.as_bytes()[0], challenge.as_bytes()[1], - challenge.as_bytes()[2], challenge.as_bytes()[3]); - println!(); - - // Step 2: Bob signs the challenge with his private key - // The signature covers: SHA256("fips-auth-v1" || challenge || timestamp) - // - Domain prefix prevents cross-protocol signature reuse - // - Timestamp enables replay attack detection - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - println!(" [Bob] Signing challenge with timestamp {}", timestamp); - println!(" Digest = SHA256(\"fips-auth-v1\" || challenge || timestamp)"); - - let response = bob.sign_challenge(challenge.as_bytes(), timestamp); - println!(" Signature created (64 bytes)"); - println!(); - - // Step 3: Alice verifies the response - // If valid, she now knows Bob controls the private key for his claimed npub - println!(" [Alice] Verifying Bob's response..."); - println!(" - Checking signature against claimed npub"); - println!(" - Checking timestamp is within acceptable window"); - - match challenge.verify(&response) { - Ok(node_id) => { - println!(); - println!(" [Alice] SUCCESS: Bob proved ownership of his npub"); - println!(" Verified node_id: {}", node_id); - println!(" Bob is now an authenticated peer"); - } + let (config, loaded_paths) = match Config::load() { + Ok(result) => result, Err(e) => { - println!(); - println!(" [Alice] FAILED: {}", e); - println!(" Connection would be terminated"); + eprintln!(" Error loading config: {}", e); + std::process::exit(1); + } + }; + + if loaded_paths.is_empty() { + println!(" No config files found, using defaults."); + } else { + println!(" Loaded {} config file(s):", loaded_paths.len()); + for path in &loaded_paths { + println!(" - {}", path.display()); } } - // Deterministic identity from secret - println!("\n5. Deterministic identity from secret bytes..."); - let secret: [u8; 32] = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, - 0x1e, 0x1f, - ]; - let fixed = Identity::from_secret_bytes(&secret).unwrap(); - println!(" npub: {}", fixed.npub()); + // Create identity from configuration + println!("\n2. Initializing identity..."); + let identity = match config.create_identity() { + Ok(id) => id, + Err(e) => { + eprintln!(" Error creating identity: {}", e); + std::process::exit(1); + } + }; - let fixed2 = Identity::from_secret_bytes(&secret).unwrap(); - println!(" Same secret produces same npub: {}", fixed.npub() == fixed2.npub()); + if config.has_identity() { + println!(" Using configured identity."); + } else { + println!(" No identity configured, generated new keypair."); + } - println!("\nDone."); + println!(); + if let Some(nsec) = &config.identity.nsec { + println!(" nsec: {}", nsec); + } else { + println!(" nsec: (generated)"); + } + println!(" npub: {}", identity.npub()); + println!(" node_id: {}", identity.node_id()); + println!(" IPv6 address: {}", identity.address()); + + println!("\nReady."); }