From 42eff09aa69e56a08ee13096719d99aa6ed6e4cf Mon Sep 17 00:00:00 2001 From: Montez Dot <21373685+m-005@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:52:17 -0500 Subject: [PATCH 1/2] Config Loading - TOML file I/O and env overrides Pull Request: https://github.com/OBJECTSHQ/protocol/pull/46 --- Cargo.lock | 11 ++ bins/objects-node/Cargo.toml | 4 + bins/objects-node/src/config.rs | 286 ++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 87abc25..62e880c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3985,6 +3985,8 @@ dependencies = [ "objects-transport", "serde", "serde_json", + "temp-env", + "tempfile", "thiserror 2.0.17", "tokio", "toml", @@ -6081,6 +6083,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.24.0" diff --git a/bins/objects-node/Cargo.toml b/bins/objects-node/Cargo.toml index 30d2db8..ad78b9f 100644 --- a/bins/objects-node/Cargo.toml +++ b/bins/objects-node/Cargo.toml @@ -28,3 +28,7 @@ thiserror.workspace = true # Logging tracing.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile = "3" +temp-env = "0.3" diff --git a/bins/objects-node/src/config.rs b/bins/objects-node/src/config.rs index 33afd42..938c59b 100644 --- a/bins/objects-node/src/config.rs +++ b/bins/objects-node/src/config.rs @@ -1,12 +1,39 @@ //! Configuration types for the OBJECTS node daemon. use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::path::Path; +use thiserror::Error; + +/// Errors that can occur during configuration loading and validation. +#[derive(Debug, Error)] +pub enum ConfigError { + /// I/O error reading or writing configuration file. + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Error parsing TOML configuration file. + #[error("Failed to parse config: {0}")] + ParseError(#[from] toml::de::Error), + + /// Configuration validation failed. + #[error("Invalid configuration: {0}")] + ValidationError(String), +} + +/// Result type for configuration operations. +pub type Result = std::result::Result; /// Main configuration for the OBJECTS node daemon. /// /// Configuration is loaded from a TOML file and can be overridden by environment variables. /// See individual field documentation for environment variable names. /// +/// Configuration precedence (highest to lowest): +/// 1. Environment variables +/// 2. Config file values +/// 3. Default values +/// /// # Example /// /// ``` @@ -28,6 +55,130 @@ pub struct NodeConfig { pub identity: IdentitySettings, } +impl NodeConfig { + /// Load configuration from environment variables only. + /// + /// Starts with default values and applies environment variable overrides. + pub fn from_env() -> Result { + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate()?; + Ok(config) + } + + /// Load configuration from a TOML file, creating it with defaults if it doesn't exist. + /// + /// If the file exists, it is loaded and environment variables are applied as overrides. + /// If the file doesn't exist, a default configuration is created and saved to the file. + pub fn load_or_create(path: &Path) -> Result { + if path.exists() { + let mut config = Self::load(path)?; + config.apply_env_overrides(); + config.validate()?; + Ok(config) + } else { + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate()?; + config.save(path)?; + Ok(config) + } + } + + /// Load configuration from a TOML file. + /// + /// Returns an error if the file doesn't exist. + pub fn load(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&contents)?; + Ok(config) + } + + /// Save configuration to a TOML file. + /// + /// Creates parent directories if they don't exist. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let contents = toml::to_string_pretty(self).map_err(|e| { + ConfigError::ValidationError(format!("Failed to serialize config: {}", e)) + })?; + + std::fs::write(path, contents)?; + Ok(()) + } + + /// Validate configuration values. + /// + /// Checks: + /// - API port is in valid range (1024-65535) + /// - API bind address is a valid IP address + /// - Relay URL uses HTTPS + /// - Discovery topic matches RFC-002 format + pub fn validate(&self) -> Result<()> { + // Validate API port + if self.node.api_port < 1024 { + return Err(ConfigError::ValidationError(format!( + "API port {} is below 1024 (reserved range)", + self.node.api_port + ))); + } + + // Validate API bind address + self.node.api_bind.parse::().map_err(|e| { + ConfigError::ValidationError(format!("Invalid API bind address: {}", e)) + })?; + + // Validate relay URL uses HTTPS + if !self.network.relay_url.starts_with("https://") { + return Err(ConfigError::ValidationError( + "Relay URL must use HTTPS".to_string(), + )); + } + + // Validate discovery topic format + if !self.network.discovery_topic.starts_with("/objects/") + || !self.network.discovery_topic.ends_with("/discovery") + { + return Err(ConfigError::ValidationError(format!( + "Discovery topic '{}' must match format '/objects/{{network}}/{{version}}/discovery'", + self.network.discovery_topic + ))); + } + + Ok(()) + } + + /// Apply environment variable overrides to configuration. + /// + /// Supported environment variables: + /// - `OBJECTS_DATA_DIR` + /// - `OBJECTS_API_PORT` + /// - `OBJECTS_RELAY_URL` + /// - `OBJECTS_REGISTRY_URL` + fn apply_env_overrides(&mut self) { + if let Ok(data_dir) = std::env::var("OBJECTS_DATA_DIR") { + self.node.data_dir = data_dir; + } + + if let Ok(api_port) = std::env::var("OBJECTS_API_PORT") + && let Ok(port) = api_port.parse::() + { + self.node.api_port = port; + } + + if let Ok(relay_url) = std::env::var("OBJECTS_RELAY_URL") { + self.network.relay_url = relay_url; + } + + if let Ok(registry_url) = std::env::var("OBJECTS_REGISTRY_URL") { + self.identity.registry_url = registry_url; + } + } +} + /// Node-specific configuration settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeSettings { @@ -153,4 +304,139 @@ mod tests { assert_eq!(config.storage.max_blob_size_mb, 100); assert_eq!(config.storage.max_total_size_gb, 10); } + + #[test] + fn test_load_or_create_missing_file() { + // Ensure no env vars interfere with this test + temp_env::with_vars( + [ + ("OBJECTS_DATA_DIR", None::<&str>), + ("OBJECTS_API_PORT", None::<&str>), + ("OBJECTS_RELAY_URL", None::<&str>), + ("OBJECTS_REGISTRY_URL", None::<&str>), + ], + || { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + // File doesn't exist yet + assert!(!config_path.exists()); + + // Load or create should create the file + let config = NodeConfig::load_or_create(&config_path).unwrap(); + + // File should now exist + assert!(config_path.exists()); + + // Should have default values + assert_eq!(config.node.api_port, 3420); + }, + ); + } + + #[test] + fn test_load_missing_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("missing.toml"); + + // Should return error + let result = NodeConfig::load(&config_path); + assert!(result.is_err()); + } + + #[test] + fn test_save_and_load_roundtrip() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut config = NodeConfig::default(); + config.node.api_port = 8080; + config.node.data_dir = "/custom/path".to_string(); + + // Save + config.save(&config_path).unwrap(); + + // Load + let loaded = NodeConfig::load(&config_path).unwrap(); + + // Verify + assert_eq!(loaded.node.api_port, 8080); + assert_eq!(loaded.node.data_dir, "/custom/path"); + } + + #[test] + fn test_env_overrides() { + temp_env::with_vars( + [ + ("OBJECTS_DATA_DIR", Some("/env/data")), + ("OBJECTS_API_PORT", Some("9000")), + ("OBJECTS_RELAY_URL", Some("https://relay.example.com")), + ("OBJECTS_REGISTRY_URL", Some("https://registry.example.com")), + ], + || { + let config = NodeConfig::from_env().unwrap(); + + assert_eq!(config.node.data_dir, "/env/data"); + assert_eq!(config.node.api_port, 9000); + assert_eq!(config.network.relay_url, "https://relay.example.com"); + assert_eq!(config.identity.registry_url, "https://registry.example.com"); + }, + ); + } + + #[test] + fn test_validation_invalid_port() { + let mut config = NodeConfig::default(); + config.node.api_port = 500; // Below 1024 + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("below 1024")); + } + + #[test] + fn test_validation_invalid_ip() { + let mut config = NodeConfig::default(); + config.node.api_bind = "not-an-ip".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid API bind address") + ); + } + + #[test] + fn test_validation_non_https_relay() { + let mut config = NodeConfig::default(); + config.network.relay_url = "http://insecure.relay.com".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must use HTTPS")); + } + + #[test] + fn test_validation_invalid_discovery_topic() { + let mut config = NodeConfig::default(); + config.network.discovery_topic = "/invalid/topic".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("must match format") + ); + } + + #[test] + fn test_validation_valid_config() { + let config = NodeConfig::default(); + assert!(config.validate().is_ok()); + } } From 531e04047cd3fbbba6029e88be0e2b8d4a0feee3 Mon Sep 17 00:00:00 2001 From: Montez Dot <21373685+m-005@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:56:17 -0500 Subject: [PATCH 2/2] State Types - NodeState struct definitions Part 3 of the Foundation stage for objects-node daemon. ## Changes Adds state type definitions for persistent node state: - NodeState - Persistent state with node keypair and optional identity - Uses SecretKey from objects-transport (Iroh Ed25519 key) - Optional IdentityInfo for registered OBJECTS identities - IdentityInfo - RFC-001 identity linking: - Identity ID (obj_ + base58 hash) - Registered handle - 8-byte nonce for ID derivation - Signer type (Passkey or Wallet) ## Security Comprehensive documentation on security requirements: - State files must use 600 permissions (owner read/write only) - Node key must be kept secure and never committed to version control - Supports anonymous mode (no identity) for nodes that don't publish assets ## Testing - Serialization round-trip tests - Identity persistence tests - Both Passkey and Wallet signer types validated --- Cargo.lock | 1 + bins/objects-node/Cargo.toml | 1 + bins/objects-node/src/main.rs | 1 + bins/objects-node/src/state.rs | 159 +++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 bins/objects-node/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 62e880c..36984e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3983,6 +3983,7 @@ dependencies = [ "objects-identity", "objects-sync", "objects-transport", + "rand 0.9.2", "serde", "serde_json", "temp-env", diff --git a/bins/objects-node/Cargo.toml b/bins/objects-node/Cargo.toml index ad78b9f..9616362 100644 --- a/bins/objects-node/Cargo.toml +++ b/bins/objects-node/Cargo.toml @@ -32,3 +32,4 @@ tracing-subscriber.workspace = true [dev-dependencies] tempfile = "3" temp-env = "0.3" +rand.workspace = true diff --git a/bins/objects-node/src/main.rs b/bins/objects-node/src/main.rs index 2d186ba..7ae866a 100644 --- a/bins/objects-node/src/main.rs +++ b/bins/objects-node/src/main.rs @@ -1,6 +1,7 @@ //! Node daemon for OBJECTS Protocol. pub mod config; +pub mod state; use tracing::info; diff --git a/bins/objects-node/src/state.rs b/bins/objects-node/src/state.rs new file mode 100644 index 0000000..3bcd3c9 --- /dev/null +++ b/bins/objects-node/src/state.rs @@ -0,0 +1,159 @@ +//! Node state types for persistent storage. +//! +//! The node state contains: +//! - Node keypair for transport-level authentication +//! - Optional OBJECTS identity information (if registered) + +use objects_identity::SignerType; +use objects_transport::SecretKey; +use serde::{Deserialize, Serialize}; + +/// Persistent state for the OBJECTS node daemon. +/// +/// This state is stored on disk and loaded when the node starts. +/// +/// # Security +/// +/// The state file contains the node's private key (`node_key`) and MUST be +/// protected with restrictive file permissions (600 - owner read/write only). +/// Never commit this file to version control or share it. +/// +/// # Fields +/// +/// - `node_key`: The node's Ed25519 private key for transport-level authentication. +/// This is separate from the OBJECTS identity system and is used by Iroh for +/// peer-to-peer connections. +/// +/// - `identity`: Optional OBJECTS identity information. `None` if the node has not +/// been registered with an identity yet. Once registered, this contains the +/// identity ID, handle, nonce, and signer type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeState { + /// Node's Ed25519 private key for transport authentication. + /// + /// This key is used by Iroh for node-to-node connections and is separate + /// from the OBJECTS identity system. Keep this secure. + pub node_key: SecretKey, + + /// OBJECTS identity information, if registered. + /// + /// `None` if the node hasn't been registered with an OBJECTS identity yet. + /// A node can operate without an OBJECTS identity (anonymous mode) but cannot + /// publish assets or participate in identity-gated features. + pub identity: Option, +} + +/// Information about a registered OBJECTS identity. +/// +/// This links the node to an OBJECTS identity (RFC-001). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityInfo { + /// RFC-001 identity ID (format: `obj_` + base58 encoded hash). + /// + /// Example: `obj_2dMiYc8RhnYkorPc5pVh9` + pub identity_id: String, + + /// Registered handle for this identity. + /// + /// Must follow RFC-001 handle rules: 1-30 chars, lowercase alphanumeric + + /// underscore + period, no leading `_` or `.`, no trailing `.`, no consecutive `..`. + pub handle: String, + + /// 8-byte nonce used for identity ID derivation. + /// + /// Per RFC-001, the identity ID is derived from SHA-256(signer_public_key || nonce). + /// This nonce is required for verification of the identity ID. + pub nonce: [u8; 8], + + /// Type of signer used for this identity. + /// + /// - `Passkey`: WebAuthn/FIDO2 credential (secp256r1/P-256) + /// - `Wallet`: Ethereum EOA (secp256k1) + pub signer_type: SignerType, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_node_state_serialization() { + // Generate a test node key + let node_key = SecretKey::generate(&mut rand::rng()); + + let state = NodeState { + node_key: node_key.clone(), + identity: None, + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&state).unwrap(); + + // Deserialize back + let deserialized: NodeState = serde_json::from_str(&json).unwrap(); + + // Verify round-trip (compare public keys since SecretKey doesn't implement PartialEq) + assert_eq!( + node_key.public().to_string(), + deserialized.node_key.public().to_string() + ); + assert!(deserialized.identity.is_none()); + } + + #[test] + fn test_node_state_with_identity() { + let node_key = SecretKey::generate(&mut rand::rng()); + + let identity_info = IdentityInfo { + identity_id: "obj_2dMiYc8RhnYkorPc5pVh9".to_string(), + handle: "test_user".to_string(), + nonce: [1, 2, 3, 4, 5, 6, 7, 8], + signer_type: SignerType::Passkey, + }; + + let state = NodeState { + node_key: node_key.clone(), + identity: Some(identity_info.clone()), + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&state).unwrap(); + + // Deserialize back + let deserialized: NodeState = serde_json::from_str(&json).unwrap(); + + // Verify identity info + let deser_identity = deserialized.identity.unwrap(); + assert_eq!(deser_identity.identity_id, "obj_2dMiYc8RhnYkorPc5pVh9"); + assert_eq!(deser_identity.handle, "test_user"); + assert_eq!(deser_identity.nonce, [1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(deser_identity.signer_type, SignerType::Passkey); + } + + #[test] + fn test_identity_info_signer_types() { + let passkey_identity = IdentityInfo { + identity_id: "obj_test1".to_string(), + handle: "passkey_user".to_string(), + nonce: [0; 8], + signer_type: SignerType::Passkey, + }; + + let wallet_identity = IdentityInfo { + identity_id: "obj_test2".to_string(), + handle: "wallet_user".to_string(), + nonce: [1; 8], + signer_type: SignerType::Wallet, + }; + + // Serialize and deserialize both + let passkey_json = serde_json::to_string(&passkey_identity).unwrap(); + let wallet_json = serde_json::to_string(&wallet_identity).unwrap(); + + let passkey_deser: IdentityInfo = serde_json::from_str(&passkey_json).unwrap(); + let wallet_deser: IdentityInfo = serde_json::from_str(&wallet_json).unwrap(); + + assert_eq!(passkey_deser.signer_type, SignerType::Passkey); + assert_eq!(wallet_deser.signer_type, SignerType::Wallet); + } +}