mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
Generated
+244
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user