Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
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}