Skip to main content

symbi_runtime/secrets/
file_backend.rs

1//! File-based secrets backend implementation
2//!
3//! This module provides a file-based secrets store that supports encrypted storage
4//! using AES-256-GCM with various key providers (environment variables, OS keychain).
5
6use super::{BoxedAuditSink, Secret, SecretAuditEvent, SecretError, SecretStore};
7use crate::crypto::{Aes256GcmCrypto, CryptoError, EncryptedData, KeyUtils};
8use crate::secrets::config::{FileConfig, FileFormat};
9use async_trait::async_trait;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::time::SystemTime;
13use tokio::sync::RwLock;
14
15/// File-based secrets store implementation
16pub struct FileSecretStore {
17    config: FileConfig,
18    audit_sink: Option<BoxedAuditSink>,
19    agent_id: String,
20    cache: RwLock<Option<(SystemTime, HashMap<String, String>)>>,
21}
22
23impl FileSecretStore {
24    /// Create a new FileSecretStore with the given configuration
25    pub async fn new(
26        config: FileConfig,
27        audit_sink: Option<BoxedAuditSink>,
28        agent_id: String,
29    ) -> Result<Self, SecretError> {
30        Ok(Self {
31            config,
32            audit_sink,
33            agent_id,
34            cache: RwLock::new(None),
35        })
36    }
37
38    /// Log an audit event if an audit sink is configured.
39    /// In strict mode, returns an error if audit logging fails.
40    /// In permissive mode, logs a warning and continues.
41    async fn log_audit_event(&self, event: SecretAuditEvent) -> Result<(), SecretError> {
42        if let Some(audit_sink) = &self.audit_sink {
43            if let Err(e) = audit_sink.log_event(event).await {
44                match audit_sink.failure_mode() {
45                    crate::secrets::auditing::AuditFailureMode::Strict => {
46                        return Err(SecretError::AuditFailed {
47                            message: format!("Audit logging failed (strict mode): {}", e),
48                        });
49                    }
50                    crate::secrets::auditing::AuditFailureMode::Permissive => {
51                        tracing::warn!("Audit logging failed (permissive mode): {}", e);
52                    }
53                }
54            }
55        }
56        Ok(())
57    }
58
59    /// Load secrets with mtime-based caching.
60    ///
61    /// Returns cached data if the file's mtime hasn't changed, avoiding
62    /// expensive re-decryption (Argon2 KDF) on every call.
63    ///
64    /// To avoid a TOCTOU race (stat then open), we open the file first and
65    /// obtain mtime from the open file handle. This ensures the mtime we
66    /// check corresponds to the file we actually read.
67    async fn load_secrets_cached(&self) -> Result<HashMap<String, String>, SecretError> {
68        let path = self.config.path.clone();
69
70        // Open the file first, then get mtime from the file handle to avoid
71        // TOCTOU: the file could be replaced between a separate stat() and open().
72        let mtime = tokio::task::spawn_blocking(move || -> Result<SystemTime, SecretError> {
73            let file = std::fs::File::open(&path).map_err(|e| SecretError::IoError {
74                message: format!("Failed to open secrets file for mtime check: {}", e),
75            })?;
76            file.metadata()
77                .and_then(|m| m.modified())
78                .map_err(|e| SecretError::IoError {
79                    message: format!("Failed to get mtime from open file handle: {}", e),
80                })
81        })
82        .await
83        .map_err(|e| SecretError::IoError {
84            message: format!("Blocking task panicked: {}", e),
85        })??;
86
87        // Fast path: return cached data if mtime matches
88        {
89            let guard = self.cache.read().await;
90            if let Some((cached_mtime, ref secrets)) = *guard {
91                if cached_mtime == mtime {
92                    return Ok(secrets.clone());
93                }
94            }
95        }
96
97        // Slow path: reload from disk (load_secrets opens and locks the file)
98        let secrets = self.load_secrets().await?;
99
100        // Update cache
101        {
102            let mut guard = self.cache.write().await;
103            *guard = Some((mtime, secrets.clone()));
104        }
105
106        Ok(secrets)
107    }
108
109    /// Load and decrypt secrets from the file.
110    ///
111    /// Acquires a shared (read) file lock via `fd_lock::RwLock` so that
112    /// concurrent readers never observe a partially-written file.
113    /// The lock is released automatically when the guard drops.
114    async fn load_secrets(&self) -> Result<HashMap<String, String>, SecretError> {
115        let path = self.config.path.clone();
116        let encryption_enabled = self.config.encryption.enabled;
117
118        // Blocking I/O with file lock — run off the async executor
119        let file_content = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, SecretError> {
120            let file = std::fs::File::open(&path).map_err(|e| SecretError::IoError {
121                message: format!("Failed to open secrets file: {}", e),
122            })?;
123
124            let lock = fd_lock::RwLock::new(file);
125            let guard = lock.read().map_err(|e| SecretError::IoError {
126                message: format!("Failed to acquire read lock on secrets file: {}", e),
127            })?;
128
129            // Read via &File (std::io::Read is impl'd for &File)
130            use std::io::Read;
131            let mut buf = Vec::new();
132            (&*guard)
133                .read_to_end(&mut buf)
134                .map_err(|e| SecretError::IoError {
135                    message: format!("Failed to read secrets file: {}", e),
136                })?;
137            // Lock released when `guard` drops here
138            Ok(buf)
139        })
140        .await
141        .map_err(|e| SecretError::IoError {
142            message: format!("Blocking task panicked: {}", e),
143        })??;
144
145        let secrets_data = if encryption_enabled {
146            // Decrypt the content
147            self.decrypt_content(&file_content).await?
148        } else {
149            // Use content as-is
150            String::from_utf8(file_content).map_err(|e| SecretError::ParseError {
151                message: format!("Invalid UTF-8 in secrets file: {}", e),
152            })?
153        };
154
155        // Parse the content based on format
156        self.parse_secrets_data(&secrets_data)
157    }
158
159    /// Decrypt file content using the configured key provider
160    async fn decrypt_content(&self, encrypted_content: &[u8]) -> Result<String, SecretError> {
161        // Get the decryption key
162        let key = self.get_decryption_key().await?;
163
164        // Parse the encrypted content as JSON to get the EncryptedData structure
165        let encrypted_data: EncryptedData =
166            serde_json::from_slice(encrypted_content).map_err(|e| SecretError::ParseError {
167                message: format!("Failed to parse encrypted data: {}", e),
168            })?;
169
170        // Verify the algorithm matches our configuration
171        if encrypted_data.algorithm != self.config.encryption.algorithm {
172            return Err(SecretError::CryptoError {
173                message: format!(
174                    "Algorithm mismatch: expected {}, found {}",
175                    self.config.encryption.algorithm, encrypted_data.algorithm
176                ),
177            });
178        }
179
180        // Decrypt the content
181        let decrypted_bytes = Aes256GcmCrypto::decrypt_with_password(&encrypted_data, &key)
182            .map_err(|e| self.map_crypto_error(e))?;
183
184        String::from_utf8(decrypted_bytes).map_err(|e| SecretError::ParseError {
185            message: format!("Decrypted content is not valid UTF-8: {}", e),
186        })
187    }
188
189    /// Get the decryption key from the configured provider
190    async fn get_decryption_key(&self) -> Result<String, SecretError> {
191        match self.config.encryption.key.provider.as_str() {
192            "env" => {
193                let env_var = self.config.encryption.key.env_var.as_ref().ok_or_else(|| {
194                    SecretError::ConfigurationError {
195                        message: "Environment variable name not specified for 'env' key provider"
196                            .to_string(),
197                    }
198                })?;
199
200                KeyUtils::get_key_from_env(env_var).map_err(|e| self.map_crypto_error(e))
201            }
202            "os_keychain" => {
203                let service = self.config.encryption.key.service.as_ref().ok_or_else(|| {
204                    SecretError::ConfigurationError {
205                        message: "Service name not specified for 'os_keychain' key provider"
206                            .to_string(),
207                    }
208                })?;
209
210                let account = self.config.encryption.key.account.as_ref().ok_or_else(|| {
211                    SecretError::ConfigurationError {
212                        message: "Account name not specified for 'os_keychain' key provider"
213                            .to_string(),
214                    }
215                })?;
216
217                let key_utils = KeyUtils::new();
218                key_utils
219                    .get_key_from_keychain(service, account)
220                    .map_err(|e| self.map_crypto_error(e))
221            }
222            "file" => {
223                let file_path = self
224                    .config
225                    .encryption
226                    .key
227                    .file_path
228                    .as_ref()
229                    .ok_or_else(|| SecretError::ConfigurationError {
230                        message: "File path not specified for 'file' key provider".to_string(),
231                    })?;
232
233                tokio::fs::read_to_string(file_path)
234                    .await
235                    .map(|content| content.trim().to_string())
236                    .map_err(|e| SecretError::IoError {
237                        message: format!("Failed to read key file: {}", e),
238                    })
239            }
240            _ => Err(SecretError::ConfigurationError {
241                message: format!(
242                    "Unsupported key provider: {}",
243                    self.config.encryption.key.provider
244                ),
245            }),
246        }
247    }
248
249    /// Parse secrets data based on the configured format
250    fn parse_secrets_data(&self, data: &str) -> Result<HashMap<String, String>, SecretError> {
251        match self.config.format {
252            FileFormat::Json => self.parse_json_secrets(data),
253            FileFormat::Yaml => self.parse_yaml_secrets(data),
254            FileFormat::Toml => self.parse_toml_secrets(data),
255            FileFormat::Env => self.parse_env_secrets(data),
256        }
257    }
258
259    /// Parse JSON format secrets
260    fn parse_json_secrets(&self, data: &str) -> Result<HashMap<String, String>, SecretError> {
261        let value: Value = serde_json::from_str(data).map_err(|e| SecretError::ParseError {
262            message: format!("Failed to parse JSON: {}", e),
263        })?;
264
265        let mut secrets = HashMap::new();
266        if let Value::Object(map) = value {
267            for (key, value) in map {
268                let secret_value = match value {
269                    Value::String(s) => s,
270                    _ => value.to_string(),
271                };
272                secrets.insert(key, secret_value);
273            }
274        } else {
275            return Err(SecretError::ParseError {
276                message: "JSON root must be an object".to_string(),
277            });
278        }
279
280        Ok(secrets)
281    }
282
283    /// Maximum size for secrets files (1 MB). Prevents DoS via oversized input.
284    const MAX_SECRETS_FILE_SIZE: usize = 1_048_576;
285
286    /// Parse YAML format secrets
287    fn parse_yaml_secrets(&self, data: &str) -> Result<HashMap<String, String>, SecretError> {
288        if data.len() > Self::MAX_SECRETS_FILE_SIZE {
289            return Err(SecretError::ParseError {
290                message: format!(
291                    "Secrets file exceeds maximum size ({} bytes > {} byte limit)",
292                    data.len(),
293                    Self::MAX_SECRETS_FILE_SIZE
294                ),
295            });
296        }
297        let value: serde_yaml::Value =
298            serde_yaml::from_str(data).map_err(|e| SecretError::ParseError {
299                message: format!("Failed to parse YAML: {}", e),
300            })?;
301
302        let mut secrets = HashMap::new();
303        if let serde_yaml::Value::Mapping(map) = value {
304            for (key, value) in map {
305                if let serde_yaml::Value::String(key_str) = key {
306                    let secret_value = match value {
307                        serde_yaml::Value::String(s) => s,
308                        _ => {
309                            serde_yaml::to_string(&value).map_err(|e| SecretError::ParseError {
310                                message: format!("Failed to serialize YAML value: {}", e),
311                            })?
312                        }
313                    };
314                    secrets.insert(key_str, secret_value);
315                }
316            }
317        } else {
318            return Err(SecretError::ParseError {
319                message: "YAML root must be a mapping".to_string(),
320            });
321        }
322
323        Ok(secrets)
324    }
325
326    /// Parse TOML format secrets
327    fn parse_toml_secrets(&self, data: &str) -> Result<HashMap<String, String>, SecretError> {
328        if data.len() > Self::MAX_SECRETS_FILE_SIZE {
329            return Err(SecretError::ParseError {
330                message: format!(
331                    "Secrets file exceeds maximum size ({} bytes > {} byte limit)",
332                    data.len(),
333                    Self::MAX_SECRETS_FILE_SIZE
334                ),
335            });
336        }
337        let value: toml::Value = toml::from_str(data).map_err(|e| SecretError::ParseError {
338            message: format!("Failed to parse TOML: {}", e),
339        })?;
340
341        let mut secrets = HashMap::new();
342        if let toml::Value::Table(table) = value {
343            for (key, value) in table {
344                let secret_value = match value {
345                    toml::Value::String(s) => s,
346                    _ => value.to_string(),
347                };
348                secrets.insert(key, secret_value);
349            }
350        } else {
351            return Err(SecretError::ParseError {
352                message: "TOML root must be a table".to_string(),
353            });
354        }
355
356        Ok(secrets)
357    }
358
359    /// Parse environment file format secrets (key=value pairs) using dotenvy
360    /// for robust handling of multiline values, escape sequences, export prefix, etc.
361    fn parse_env_secrets(&self, data: &str) -> Result<HashMap<String, String>, SecretError> {
362        let mut secrets = HashMap::new();
363        for item in dotenvy::from_read_iter(data.as_bytes()) {
364            match item {
365                Ok((key, value)) => {
366                    secrets.insert(key, value);
367                }
368                Err(e) => {
369                    return Err(SecretError::ParseError {
370                        message: format!("Failed to parse env file: {}", e),
371                    });
372                }
373            }
374        }
375        Ok(secrets)
376    }
377
378    /// Map crypto errors to secret errors
379    fn map_crypto_error(&self, error: CryptoError) -> SecretError {
380        SecretError::CryptoError {
381            message: error.to_string(),
382        }
383    }
384}
385
386#[async_trait]
387impl SecretStore for FileSecretStore {
388    /// Retrieve a secret by key
389    async fn get_secret(&self, key: &str) -> Result<Secret, SecretError> {
390        // Intent log — ensures a paper trail even if the process crashes
391        // during decryption or file I/O.
392        self.log_audit_event(SecretAuditEvent::attempt(
393            self.agent_id.clone(),
394            "get_secret".to_string(),
395            Some(key.to_string()),
396        ))
397        .await?;
398
399        let result: Result<Secret, SecretError> = async {
400            let secrets = self.load_secrets_cached().await?;
401
402            match secrets.get(key) {
403                Some(value) => Ok(Secret::new(key.to_string(), value.clone())),
404                None => Err(SecretError::NotFound {
405                    key: key.to_string(),
406                }),
407            }
408        }
409        .await;
410
411        // Log audit event — in strict mode, audit failure blocks the operation
412        let audit_event = match &result {
413            Ok(_) => SecretAuditEvent::success(
414                self.agent_id.clone(),
415                "get_secret".to_string(),
416                Some(key.to_string()),
417            ),
418            Err(e) => SecretAuditEvent::failure(
419                self.agent_id.clone(),
420                "get_secret".to_string(),
421                Some(key.to_string()),
422                e.to_string(),
423            ),
424        };
425        self.log_audit_event(audit_event).await?;
426
427        result
428    }
429
430    /// List all available secret keys, optionally filtered by prefix
431    async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
432        self.log_audit_event(SecretAuditEvent::attempt(
433            self.agent_id.clone(),
434            "list_secrets".to_string(),
435            None,
436        ))
437        .await?;
438
439        let result: Result<Vec<String>, SecretError> = async {
440            let secrets = self.load_secrets_cached().await?;
441            Ok(secrets.keys().cloned().collect())
442        }
443        .await;
444
445        // Log audit event — in strict mode, audit failure blocks the operation
446        let audit_event = match &result {
447            Ok(keys) => {
448                SecretAuditEvent::success(self.agent_id.clone(), "list_secrets".to_string(), None)
449                    .with_metadata(serde_json::json!({
450                        "secrets_count": keys.len()
451                    }))
452            }
453            Err(e) => SecretAuditEvent::failure(
454                self.agent_id.clone(),
455                "list_secrets".to_string(),
456                None,
457                e.to_string(),
458            ),
459        };
460        self.log_audit_event(audit_event).await?;
461
462        result
463    }
464}
465
466/// Extension trait for prefix filtering
467impl FileSecretStore {
468    /// List secrets with prefix filtering
469    pub async fn list_secrets_with_prefix(&self, prefix: &str) -> Result<Vec<String>, SecretError> {
470        let secrets = self.load_secrets_cached().await?;
471        Ok(secrets
472            .keys()
473            .filter(|key| key.starts_with(prefix))
474            .cloned()
475            .collect())
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use std::io::Write;
483    use std::path::PathBuf;
484    use tempfile::NamedTempFile;
485
486    fn create_test_config(path: PathBuf) -> FileConfig {
487        FileConfig {
488            path,
489            format: FileFormat::Json,
490            encryption: crate::secrets::config::FileEncryptionConfig {
491                enabled: false,
492                algorithm: "AES-256-GCM".to_string(),
493                kdf: "Argon2".to_string(),
494                key: crate::secrets::config::FileKeyConfig {
495                    provider: "env".to_string(),
496                    env_var: Some("TEST_KEY".to_string()),
497                    service: None,
498                    account: None,
499                    file_path: None,
500                },
501            },
502            permissions: Some(0o600),
503            watch_for_changes: false,
504            backup: crate::secrets::config::FileBackupConfig::default(),
505        }
506    }
507
508    #[tokio::test]
509    async fn test_parse_json_secrets() {
510        let mut temp_file = NamedTempFile::new().unwrap();
511        writeln!(temp_file, r#"{{"key1": "value1", "key2": "value2"}}"#).unwrap();
512
513        let config = create_test_config(temp_file.path().to_path_buf());
514        let store = FileSecretStore::new(config, None, "test-agent".to_string())
515            .await
516            .unwrap();
517
518        let secret = store.get_secret("key1").await.unwrap();
519        assert_eq!(secret.value(), "value1");
520
521        let keys = store.list_secrets().await.unwrap();
522        assert!(keys.contains(&"key1".to_string()));
523        assert!(keys.contains(&"key2".to_string()));
524    }
525
526    #[tokio::test]
527    async fn test_secret_not_found() {
528        let mut temp_file = NamedTempFile::new().unwrap();
529        writeln!(temp_file, r#"{{"key1": "value1"}}"#).unwrap();
530
531        let config = create_test_config(temp_file.path().to_path_buf());
532        let store = FileSecretStore::new(config, None, "test-agent".to_string())
533            .await
534            .unwrap();
535
536        let result = store.get_secret("nonexistent").await;
537        assert!(matches!(result, Err(SecretError::NotFound { .. })));
538    }
539
540    #[tokio::test]
541    async fn test_list_secrets_with_prefix() {
542        let mut temp_file = NamedTempFile::new().unwrap();
543        writeln!(
544            temp_file,
545            r#"{{"app_key1": "value1", "app_key2": "value2", "other_key": "value3"}}"#
546        )
547        .unwrap();
548
549        let config = create_test_config(temp_file.path().to_path_buf());
550        let store = FileSecretStore::new(config, None, "test-agent".to_string())
551            .await
552            .unwrap();
553
554        let keys = store.list_secrets_with_prefix("app_").await.unwrap();
555        assert_eq!(keys.len(), 2);
556        assert!(keys.contains(&"app_key1".to_string()));
557        assert!(keys.contains(&"app_key2".to_string()));
558        assert!(!keys.contains(&"other_key".to_string()));
559    }
560
561    #[tokio::test]
562    async fn test_concurrent_reads_no_deadlock() {
563        let mut temp_file = NamedTempFile::new().unwrap();
564        writeln!(temp_file, r#"{{"secret_a": "val_a", "secret_b": "val_b"}}"#).unwrap();
565
566        let config = create_test_config(temp_file.path().to_path_buf());
567        let store = std::sync::Arc::new(
568            FileSecretStore::new(config, None, "test-agent".to_string())
569                .await
570                .unwrap(),
571        );
572
573        // Spawn multiple concurrent readers — shared read locks must not deadlock
574        let mut handles = Vec::new();
575        for _ in 0..10 {
576            let s = store.clone();
577            handles.push(tokio::spawn(async move {
578                let secret = s.get_secret("secret_a").await.unwrap();
579                assert_eq!(secret.value(), "val_a");
580            }));
581        }
582
583        for h in handles {
584            h.await.unwrap();
585        }
586    }
587}