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
Generated
+244 -1
View File
@@ -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"
+5 -1
View File
@@ -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"
+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.");
}