Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
at main 501 lines 16 kB view raw
1//! # Configuration Module 2//! 3//! Application configuration management and validation for the smokesignal event management system. 4//! 5//! This module provides a comprehensive configuration system that loads settings from environment 6//! variables, validates them at startup, and provides type-safe access throughout the application. 7//! Configuration includes database connections, cryptographic keys, external service endpoints, 8//! and operational parameters. 9//! 10//! ## Architecture 11//! 12//! The configuration system is built around several key principles: 13//! 14//! ### Type Safety 15//! All configuration values are wrapped in newtype structs to prevent misuse: 16//! - **[`HttpPort`]** - Validated HTTP server port number 17//! - **[`HttpCookieKey`]** - Cryptographically secure cookie signing key 18//! - **[`SigningKeys`]** - JSON Web Signature (JWS) signing keys for OAuth 19//! - **[`AdminDIDs`]** - List of administrative DID identifiers 20//! 21//! ### Validation 22//! Configuration values are validated at load time: 23//! - Port numbers within valid ranges 24//! - Cryptographic key format validation 25//! - URL format validation for external services 26//! - Required environment variables presence checks 27//! 28//! ### Security 29//! Sensitive configuration is handled securely: 30//! - Keys are loaded from secure sources 31//! - Sensitive values are not logged or exposed 32//! - Cryptographic material is properly managed 33//! - Environment-based configuration isolation 34//! 35//! ## Configuration Sources 36//! 37//! Configuration is loaded from multiple sources in order of precedence: 38//! 1. Environment variables (highest priority) 39//! 2. Configuration files (if specified) 40//! 3. Default values (lowest priority) 41//! 42//! ## Required Environment Variables 43//! 44//! The following environment variables must be set: 45//! 46//! ### Database and Cache 47//! - `DATABASE_URL` - PostgreSQL connection string 48//! - `REDIS_URL` - Redis/Valkey connection string for caching and sessions 49//! 50//! ### HTTP Server 51//! - `HTTP_PORT` - Port number for the web server (default: 3000) 52//! - `HTTP_COOKIE_KEY` - Base64-encoded key for cookie signing 53//! - `EXTERNAL_BASE` - Base URL for external links and callbacks 54//! 55//! ### AT Protocol 56//! - `PLC_HOSTNAME` - Personal Data Server hostname 57//! - `SIGNING_KEYS` - JSON object containing OAuth signing keys 58//! - `OAUTH_ACTIVE_KEYS` - Comma-separated list of active key IDs 59//! - `DESTINATION_KEY` - Private key for destination verification 60//! 61//! ### Optional Configuration 62//! - `CERTIFICATE_BUNDLES` - Additional CA certificate bundles 63//! - `ADMIN_DIDS` - Comma-separated list of admin DID identifiers 64//! - `DNS_NAMESERVERS` - Custom DNS servers for resolution 65//! - `USER_AGENT` - Custom User-Agent string for external requests 66//! 67//! ## Example Usage 68//! 69//! ```rust,no_run 70//! use smokesignal::config::Config; 71//! 72//! #[tokio::main] 73//! async fn main() -> anyhow::Result<()> { 74//! // Load configuration from environment 75//! let config = Config::from_env().await?; 76//! 77//! // Access configuration values 78//! println!("Server will run on port: {}", config.http_port.value()); 79//! println!("Database URL: {}", config.database_url); 80//! 81//! // Configuration is available throughout the application 82//! start_server(config).await 83//! } 84//! 85//! async fn start_server(config: Config) -> anyhow::Result<()> { 86//! // Use configuration to set up services 87//! Ok(()) 88//! } 89//! ``` 90//! 91//! ## Security Considerations 92//! 93//! When deploying smokesignal, ensure that: 94//! - All required environment variables are set 95//! - Cryptographic keys are generated securely and kept private 96//! - Database and Redis connections use appropriate authentication 97//! - The `EXTERNAL_BASE` URL uses HTTPS in production 98//! - Admin DIDs are restricted to trusted identities 99 100use anyhow::Result; 101use axum_extra::extract::cookie::Key; 102use base64::{engine::general_purpose, Engine as _}; 103use ordermap::OrderMap; 104use p256::SecretKey; 105use rand::seq::SliceRandom; 106 107use crate::config_errors::ConfigError; 108use crate::encoding_errors::EncodingError; 109use crate::jose::jwk::{WrappedJsonWebKeySet, JsonWebKey}; 110 111/// Convert a JsonWebKey to a p256::SecretKey 112pub fn convert_jwk_to_secret_key(jwk: &JsonWebKey) -> Result<SecretKey, String> { 113 // Validate this is an EC key with P-256 curve 114 if jwk.kty != "EC" { 115 return Err("Key type must be 'EC'".to_string()); 116 } 117 if jwk.crv != "P-256" { 118 return Err("Curve must be 'P-256'".to_string()); 119 } 120 121 // Extract the private key component 'd' 122 let d_value = jwk.d.as_ref() 123 .ok_or_else(|| "Missing private key component 'd'".to_string())?; 124 125 // Decode the base64url encoded private key 126 let d_bytes = base64_url::decode(d_value) 127 .map_err(|e| format!("Failed to decode private key: {}", e))?; 128 129 // Convert to p256::SecretKey - ensure we have exactly 32 bytes 130 if d_bytes.len() != 32 { 131 return Err(format!("Private key must be exactly 32 bytes, got {}", d_bytes.len())); 132 } 133 134 let mut key_bytes = [0u8; 32]; 135 key_bytes.copy_from_slice(&d_bytes); 136 137 SecretKey::from_bytes(&key_bytes.into()) 138 .map_err(|e| format!("Failed to create SecretKey: {}", e)) 139} 140 141#[derive(Clone)] 142pub struct HttpPort(u16); 143 144#[derive(Clone)] 145pub struct HttpCookieKey(Key); 146 147#[derive(Clone)] 148pub struct CertificateBundles(Vec<String>); 149 150#[derive(Clone)] 151pub struct SigningKeys(OrderMap<String, SecretKey>); 152 153#[derive(Clone)] 154pub struct OAuthActiveKeys(Vec<String>); 155 156#[derive(Clone)] 157pub struct AdminDIDs(Vec<String>); 158 159#[derive(Clone)] 160pub struct DnsNameservers(Vec<std::net::IpAddr>); 161 162#[derive(Clone)] 163pub struct Config { 164 pub version: String, 165 pub http_port: HttpPort, 166 pub http_cookie_key: HttpCookieKey, 167 pub http_static_path: String, 168 pub external_base: String, 169 pub certificate_bundles: CertificateBundles, 170 pub user_agent: String, 171 pub database_url: String, 172 pub plc_hostname: String, 173 pub signing_keys: SigningKeys, 174 pub oauth_active_keys: OAuthActiveKeys, 175 pub destination_key: SecretKey, 176 pub redis_url: String, 177 pub admin_dids: AdminDIDs, 178 pub dns_nameservers: DnsNameservers, 179} 180 181impl Config { 182 pub fn new() -> Result<Self> { 183 let http_port: HttpPort = default_env("HTTP_PORT", "8080").try_into()?; 184 185 let http_cookie_key: HttpCookieKey = 186 require_env("HTTP_COOKIE_KEY").and_then(|value| value.try_into())?; 187 188 let http_static_path = default_env("HTTP_STATIC_PATH", "static"); 189 190 let external_base = require_env("EXTERNAL_BASE")?; 191 192 let certificate_bundles: CertificateBundles = 193 optional_env("CERTIFICATE_BUNDLES").try_into()?; 194 195 let default_user_agent = 196 format!("smokesignal ({}; +https://smokesignal.events/)", version()?); 197 198 let user_agent = default_env("USER_AGENT", &default_user_agent); 199 200 let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 201 202 let database_url = default_env("DATABASE_URL", "sqlite://development.db"); 203 204 let signing_keys: SigningKeys = 205 require_env("SIGNING_KEYS").and_then(|value| value.try_into())?; 206 207 let oauth_active_keys: OAuthActiveKeys = 208 require_env("OAUTH_ACTIVE_KEYS").and_then(|value| value.try_into())?; 209 210 let destination_key = require_env("DESTINATION_KEY").and_then(|value| { 211 signing_keys 212 .0 213 .get(&value) 214 .cloned() 215 .ok_or(ConfigError::InvalidDestinationKey.into()) 216 })?; 217 218 let redis_url = default_env("REDIS_URL", "redis://valkey:6379/0"); 219 220 let admin_dids: AdminDIDs = optional_env("ADMIN_DIDS").try_into()?; 221 222 let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 223 224 Ok(Self { 225 version: version()?, 226 http_port, 227 http_static_path, 228 external_base, 229 certificate_bundles, 230 user_agent, 231 plc_hostname, 232 database_url, 233 signing_keys, 234 oauth_active_keys, 235 http_cookie_key, 236 destination_key, 237 redis_url, 238 admin_dids, 239 dns_nameservers, 240 }) 241 } 242 243 pub fn select_oauth_signing_key(&self) -> Result<(String, SecretKey)> { 244 let key_id = self 245 .oauth_active_keys 246 .as_ref() 247 .choose(&mut rand::thread_rng()) 248 .ok_or(ConfigError::SigningKeyNotFound)? 249 .clone(); 250 let signing_key = self 251 .signing_keys 252 .as_ref() 253 .get(&key_id) 254 .ok_or(ConfigError::SigningKeyNotFound)? 255 .clone(); 256 257 Ok((key_id, signing_key)) 258 } 259 260 /// Check if a DID is in the admin allow list 261 pub fn is_admin(&self, did: &str) -> bool { 262 self.admin_dids.as_ref().contains(&did.to_string()) 263 } 264} 265 266pub fn require_env(name: &str) -> Result<String> { 267 std::env::var(name).map_err(|_| ConfigError::EnvVarRequired(name.to_string()).into()) 268} 269 270pub fn optional_env(name: &str) -> String { 271 std::env::var(name).unwrap_or("".to_string()) 272} 273 274pub fn default_env(name: &str, default_value: &str) -> String { 275 std::env::var(name).unwrap_or(default_value.to_string()) 276} 277 278pub fn version() -> Result<String> { 279 option_env!("GIT_HASH") 280 .or(option_env!("CARGO_PKG_VERSION")) 281 .map(|val| val.to_string()) 282 .ok_or(ConfigError::VersionNotSet.into()) 283} 284 285impl TryFrom<String> for HttpPort { 286 type Error = anyhow::Error; 287 fn try_from(value: String) -> Result<Self, Self::Error> { 288 if value.is_empty() { 289 Ok(Self(80)) 290 } else { 291 value 292 .parse::<u16>() 293 .map(Self) 294 .map_err(|err| ConfigError::PortParsingFailed(err).into()) 295 } 296 } 297} 298 299impl AsRef<u16> for HttpPort { 300 fn as_ref(&self) -> &u16 { 301 &self.0 302 } 303} 304 305impl TryFrom<String> for HttpCookieKey { 306 type Error = anyhow::Error; 307 fn try_from(value: String) -> Result<Self, Self::Error> { 308 let mut decoded_key: [u8; 66] = [0; 66]; 309 general_purpose::STANDARD_NO_PAD 310 .decode_slice(value, &mut decoded_key) 311 .map_err(|err| anyhow::Error::from(ConfigError::CookieKeyDecodeFailed(err)))?; 312 Key::try_from(&decoded_key[..64]) 313 .map_err(|_| anyhow::Error::from(ConfigError::CookieKeyProcessFailed)) 314 .map(Self) 315 } 316} 317 318impl AsRef<Key> for HttpCookieKey { 319 fn as_ref(&self) -> &Key { 320 &self.0 321 } 322} 323 324impl TryFrom<String> for CertificateBundles { 325 type Error = anyhow::Error; 326 fn try_from(value: String) -> Result<Self, Self::Error> { 327 Ok(Self( 328 value 329 .split(';') 330 .filter_map(|s| { 331 if s.is_empty() { 332 None 333 } else { 334 Some(s.to_string()) 335 } 336 }) 337 .collect::<Vec<String>>(), 338 )) 339 } 340} 341 342impl AsRef<Vec<String>> for CertificateBundles { 343 fn as_ref(&self) -> &Vec<String> { 344 &self.0 345 } 346} 347 348impl AsRef<OrderMap<String, SecretKey>> for SigningKeys { 349 fn as_ref(&self) -> &OrderMap<String, SecretKey> { 350 &self.0 351 } 352} 353 354impl TryFrom<String> for SigningKeys { 355 type Error = anyhow::Error; 356 fn try_from(value: String) -> Result<Self, Self::Error> { 357 let content = { 358 if value.starts_with("/") { 359 // Verify file exists before reading 360 if !std::path::Path::new(&value).exists() { 361 return Err(ConfigError::SigningKeysFileNotFound(value).into()); 362 } 363 std::fs::read(&value).map_err(ConfigError::ReadSigningKeysFailed)? 364 } else { 365 general_purpose::STANDARD 366 .decode(&value) 367 .map_err(EncodingError::Base64DecodingFailed)? 368 } 369 }; 370 371 // Validate content is not empty 372 if content.is_empty() { 373 return Err(ConfigError::EmptySigningKeysFile.into()); 374 } 375 376 // Parse JSON with proper error handling 377 let jwks = serde_json::from_slice::<WrappedJsonWebKeySet>(&content) 378 .map_err(ConfigError::ParseSigningKeysFailed)?; 379 380 // Validate JWKS contains keys 381 if jwks.keys.is_empty() { 382 return Err(ConfigError::MissingKeysInJWKS.into()); 383 } 384 385 // Track keys that failed validation for better error reporting 386 let mut validation_errors = Vec::new(); 387 388 let signing_keys = jwks 389 .keys 390 .iter() 391 .filter_map(|key| { 392 // Validate key has required fields 393 if key.jwk.kid.is_none() { 394 validation_errors.push("Missing key ID (kid)".to_string()); 395 return None; 396 } 397 398 if let (Some(key_id), secret_key) = (key.jwk.kid.clone(), key.jwk.clone()) { 399 // Verify the key_id format (should be a valid ULID) 400 if ulid::Ulid::from_string(&key_id).is_err() { 401 validation_errors.push(format!("Invalid key ID format: {}", key_id)); 402 return None; 403 } 404 405 // Validate the secret key by converting JsonWebKey to p256::SecretKey 406 match convert_jwk_to_secret_key(&secret_key) { 407 Ok(secret_key) => Some((key_id.to_string(), secret_key)), 408 Err(err) => { 409 validation_errors.push(format!("Invalid key {}: {}", key_id, err)); 410 None 411 } 412 } 413 } else { 414 None 415 } 416 }) 417 .collect::<OrderMap<String, SecretKey>>(); 418 419 // Check if we have any valid keys 420 if signing_keys.is_empty() { 421 if !validation_errors.is_empty() { 422 return Err(ConfigError::SigningKeysValidationFailed(validation_errors).into()); 423 } 424 return Err(ConfigError::EmptySigningKeys.into()); 425 } 426 427 Ok(Self(signing_keys)) 428 } 429} 430 431impl AsRef<Vec<String>> for OAuthActiveKeys { 432 fn as_ref(&self) -> &Vec<String> { 433 &self.0 434 } 435} 436 437impl TryFrom<String> for OAuthActiveKeys { 438 type Error = anyhow::Error; 439 fn try_from(value: String) -> Result<Self, Self::Error> { 440 let values = value 441 .split(';') 442 .map(|s| s.to_string()) 443 .collect::<Vec<String>>(); 444 if values.is_empty() { 445 return Err(ConfigError::EmptyOAuthActiveKeys.into()); 446 } 447 Ok(Self(values)) 448 } 449} 450 451impl AsRef<Vec<String>> for AdminDIDs { 452 fn as_ref(&self) -> &Vec<String> { 453 &self.0 454 } 455} 456 457impl TryFrom<String> for AdminDIDs { 458 type Error = anyhow::Error; 459 fn try_from(value: String) -> Result<Self, Self::Error> { 460 // Allow empty value for no admins 461 if value.is_empty() { 462 return Ok(Self(Vec::new())); 463 } 464 465 let admin_dids = value 466 .split(',') 467 .map(|s| s.trim().to_string()) 468 .filter(|s| !s.is_empty()) 469 .collect::<Vec<String>>(); 470 471 Ok(Self(admin_dids)) 472 } 473} 474 475impl AsRef<Vec<std::net::IpAddr>> for DnsNameservers { 476 fn as_ref(&self) -> &Vec<std::net::IpAddr> { 477 &self.0 478 } 479} 480 481impl TryFrom<String> for DnsNameservers { 482 type Error = anyhow::Error; 483 fn try_from(value: String) -> Result<Self, Self::Error> { 484 // Allow empty value for default DNS configuration 485 if value.is_empty() { 486 return Ok(Self(Vec::new())); 487 } 488 489 let nameservers = value 490 .split(',') 491 .map(|s| s.trim()) 492 .filter(|s| !s.is_empty()) 493 .map(|s| { 494 s.parse::<std::net::IpAddr>() 495 .map_err(|e| ConfigError::NameserverParsingFailed(s.to_string(), e)) 496 }) 497 .collect::<Result<Vec<std::net::IpAddr>, ConfigError>>()?; 498 499 Ok(Self(nameservers)) 500 } 501}