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)
This commit is contained in:
Johnathan Corgan
2026-01-25 01:08:02 +00:00
parent ac2f6a75c8
commit b80b3fbecf
6 changed files with 809 additions and 96 deletions
+366
View File
@@ -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<String>,
}
/// 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<PathBuf>), 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<PathBuf>), 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<Self, ConfigError> {
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<PathBuf> {
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<Identity, ConfigError> {
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<String, serde_yaml::Error> {
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:"));
}
}
+141
View File
@@ -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<Self, IdentityError> {
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<XOnlyPublicKey, IdentityError> {
Ok(pubkey)
}
/// Encode a secret key as a bech32 nsec string (NIP-19).
pub fn encode_nsec(secret_key: &SecretKey) -> String {
bech32::encode::<Bech32>(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<SecretKey, IdentityError> {
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<SecretKey, IdentityError> {
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());
}
}
+4 -2
View File
@@ -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,
};
+49 -92
View File
@@ -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.");
}