rcman 0.1.9

Framework-agnostic settings management with schema, backup/restore, secrets and derive macro support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
//! Encrypted file backend for credential storage
//!
//! Uses AES-256-GCM for encryption, suitable for CI/Docker environments
//! where OS keychain is not available.

use super::CredentialBackend;
use super::types::SecretPasswordSource;
use crate::error::{Error, Result};
use crate::utils::sync::RwLockExt;
use aes_gcm::{
    Aes256Gcm, Nonce,
    aead::{Aead, KeyInit},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use log::debug;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, RwLock};

/// Encrypted credential entry
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EncryptedEntry {
    /// Base64-encoded nonce
    nonce: String,
    /// Base64-encoded ciphertext
    ciphertext: String,
}

/// Encrypted file storage format
#[derive(Debug, Default, Serialize, Deserialize)]
struct EncryptedStore {
    version: u32,
    /// Base64-encoded salt for Argon2id key derivation (stored plaintext, safe)
    #[serde(default)]
    salt: Option<String>,
    entries: HashMap<String, EncryptedEntry>,
}

/// Encrypted file backend using AES-256-GCM
pub struct EncryptedFileBackend {
    path: PathBuf,
    cipher: Aes256Gcm,
    /// Salt used for key derivation (stored in file for decryption on restart)
    salt: [u8; 16],
    /// Plaintext read-cache.
    ///
    /// Populated lazily on `get()` and kept in sync on `store()`/`remove()`.
    cache: RwLock<HashMap<String, String>>,
    /// Serializes all mutations to the encrypted file.
    ///
    /// BUG FIX: without this lock, two concurrent `store()` / `remove()` calls
    /// both read the file into separate in-memory copies, modify their own copy,
    /// and write back.  The second write silently drops the first writer's change.
    /// Holding this mutex across the entire read-modify-write cycle prevents the
    /// TOCTOU race.  The read cache (`self.cache`) is updated while the mutex is
    /// still held, so cache and disk are always consistent.
    write_lock: Mutex<()>,
}

impl EncryptedFileBackend {
    /// Create a new encrypted file backend
    ///
    /// # Arguments
    /// * `path` - Path to the encrypted credentials file
    /// * `key` - 32-byte encryption key (derived from password + salt)
    /// * `salt` - 16-byte salt used for key derivation (will be stored in file)
    ///
    /// # Errors
    /// Returns an error if the key length is invalid.
    pub(crate) fn new(path: PathBuf, key: &[u8; 32], salt: [u8; 16]) -> Result<Self> {
        Ok(Self {
            path,
            cipher: Aes256Gcm::new_from_slice(key)
                .map_err(|_| Error::Credential("Invalid encryption key length".into()))?,
            salt,
            cache: RwLock::new(HashMap::new()),
            write_lock: Mutex::new(()),
        })
    }

    /// Create an encrypted file backend from a password source
    ///
    /// This handles salt reading/generation and key derivation automatically.
    ///
    /// # Errors
    ///
    /// Returns an error if the password source fails to resolve (e.g., missing environment variable)
    /// or if the encrypted file exists but is corrupted.
    pub fn with_source(path: PathBuf, source: &SecretPasswordSource) -> Result<Self> {
        Self::with_password(path, &source.resolve()?)
    }

    /// Create an encrypted file backend from a password
    ///
    /// This is the recommended constructor. It handles salt automatically:
    /// - If file exists, reads salt from it
    /// - If file is new, generates a random salt
    ///
    /// # Example
    /// ```rust,no_run
    /// use std::path::PathBuf;
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use rcman::EncryptedFileBackend;
    ///
    /// let path = PathBuf::from("/tmp/credentials.enc.json");
    /// let backend = EncryptedFileBackend::with_password(path, "user_password")?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or parsed.
    pub fn with_password(path: PathBuf, password: &str) -> Result<Self> {
        let salt = Self::read_salt(&path)?.unwrap_or_else(Self::generate_salt);
        let key = Self::derive_key(password, &salt)?;
        Self::new(path, &key, salt)
    }

    /// Read the salt from an existing encrypted file (without needing the key)
    ///
    /// Returns `None` if the file doesn't exist or has no salt (v1 format).
    /// Call this FIRST, then derive the key, then create the backend.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or parsed.
    pub fn read_salt(path: &Path) -> Result<Option<[u8; 16]>> {
        if !path.exists() {
            return Ok(None);
        }

        let content = fs::read_to_string(path).map_err(|e| Error::FileRead {
            path: path.to_path_buf(),
            source: e,
        })?;

        let store: EncryptedStore = serde_json::from_str(&content)
            .map_err(|e| Error::Credential(format!("Failed to parse encrypted store: {e}")))?;

        let Some(salt_b64) = store.salt else {
            return Ok(None);
        };

        let salt_vec = BASE64
            .decode(&salt_b64)
            .map_err(|e| Error::Credential(format!("Invalid salt encoding: {e}")))?;

        if salt_vec.len() != 16 {
            return Err(Error::Credential(format!(
                "Invalid salt length: expected 16, got {}",
                salt_vec.len()
            )));
        }

        let mut salt = [0u8; 16];
        salt.copy_from_slice(&salt_vec);
        Ok(Some(salt))
    }

    /// Generate a random 32-byte encryption key
    #[must_use]
    pub fn generate_key() -> [u8; 32] {
        rand::rng().random()
    }

    /// Generate a random 16-byte salt for Argon2
    #[must_use]
    pub fn generate_salt() -> [u8; 16] {
        rand::rng().random()
    }

    /// Derive a key from a password using Argon2id
    ///
    /// Uses Argon2id (memory-hard) for state-of-the-art protection against GPU attacks.
    ///
    /// # Arguments
    /// * `password` - The user password
    /// * `salt` - A 16-byte random salt (use `generate_salt()` or `read_salt()`)
    ///
    /// # Errors
    /// Returns an error if salt encoding or hashing fails.
    pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
        use argon2::{
            Argon2,
            password_hash::{PasswordHasher, SaltString},
        };

        let salt_string = SaltString::encode_b64(salt)
            .map_err(|e| Error::Credential(format!("Invalid salt bytes: {e}")))?;

        let password_hash = Argon2::default()
            .hash_password(password.as_bytes(), &salt_string)
            .map_err(|e| Error::Credential(format!("Argon2 hashing failed: {e}")))?;

        let output = password_hash
            .hash
            .ok_or_else(|| Error::Credential("Argon2 hash output missing".into()))?;

        let bytes = output.as_bytes();

        if bytes.len() < 32 {
            return Err(Error::Credential(format!(
                "Argon2 output too short: {}",
                bytes.len()
            )));
        }

        let mut key = [0u8; 32];
        key.copy_from_slice(&bytes[..32]);
        Ok(key)
    }

    fn load_store(&self) -> Result<EncryptedStore> {
        if !self.path.exists() {
            return Ok(EncryptedStore::default());
        }

        let content = fs::read_to_string(&self.path).map_err(|e| Error::FileRead {
            path: self.path.clone(),
            source: e,
        })?;

        serde_json::from_str(&content)
            .map_err(|e| Error::Credential(format!("Failed to parse encrypted store: {e}")))
    }

    /// Serialize and atomically write the store to disk.
    ///
    /// Always writes version=1 and the backend's own salt. Entries are taken by value
    /// to avoid an extra clone when the caller is done with them.
    fn save_store(&self, entries: HashMap<String, EncryptedEntry>) -> Result<()> {
        let store = EncryptedStore {
            version: 1,
            salt: Some(BASE64.encode(self.salt)),
            entries,
        };

        let content = serde_json::to_string_pretty(&store)
            .map_err(|e| Error::Credential(format!("Failed to serialize encrypted store: {e}")))?;

        // Ensure parent directory exists
        if let Some(parent) = self.path.parent() {
            crate::utils::security::ensure_secure_dir(parent)?;
        }

        // Atomic write: write to a temp file, then rename
        let mut temp_path = self.path.clone();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        let file_name = self.path.file_name().unwrap_or_default().to_string_lossy();
        temp_path.set_file_name(format!("{file_name}.{now}.tmp"));

        let mut temp_file = fs::File::create(&temp_path).map_err(|e| Error::FileWrite {
            path: temp_path.clone(),
            source: e,
        })?;

        temp_file
            .write_all(content.as_bytes())
            .map_err(|e| Error::FileWrite {
                path: temp_path.clone(),
                source: e,
            })?;

        temp_file.sync_all().map_err(|e| Error::FileWrite {
            path: temp_path.clone(),
            source: e,
        })?;

        crate::utils::security::set_secure_file_permissions(&temp_path)?;

        fs::rename(&temp_path, &self.path).map_err(|e| Error::FileWrite {
            path: self.path.clone(),
            source: e,
        })?;

        crate::utils::security::set_secure_file_permissions(&self.path)?;

        Ok(())
    }

    fn encrypt(&self, plaintext: &str) -> Result<EncryptedEntry> {
        let nonce_bytes: [u8; 12] = rand::rng().random();
        let nonce = Nonce::from_slice(&nonce_bytes);

        let ciphertext = self
            .cipher
            .encrypt(nonce, plaintext.as_bytes())
            .map_err(|e| Error::Credential(format!("Encryption failed: {e}")))?;

        Ok(EncryptedEntry {
            nonce: BASE64.encode(nonce_bytes),
            ciphertext: BASE64.encode(&ciphertext),
        })
    }

    fn decrypt(&self, entry: &EncryptedEntry) -> Result<String> {
        let nonce_bytes = BASE64
            .decode(&entry.nonce)
            .map_err(|e| Error::Credential(format!("Invalid nonce encoding: {e}")))?;

        let ciphertext = BASE64
            .decode(&entry.ciphertext)
            .map_err(|e| Error::Credential(format!("Invalid ciphertext encoding: {e}")))?;

        let nonce = Nonce::from_slice(&nonce_bytes);

        let plaintext = self
            .cipher
            .decrypt(nonce, ciphertext.as_ref())
            .map_err(|_| Error::Credential("Decryption failed (wrong key?)".into()))?;

        String::from_utf8(plaintext)
            .map_err(|e| Error::Credential(format!("Decrypted data is not valid UTF-8: {e}")))
    }
}

impl CredentialBackend for EncryptedFileBackend {
    fn store(&self, key: &str, value: &str) -> Result<()> {
        let _guard = self
            .write_lock
            .lock()
            .map_err(|_| Error::Credential("Encrypted file write lock poisoned".into()))?;

        let mut store = self.load_store()?;
        let encrypted = self.encrypt(value)?;
        store.entries.insert(key.to_string(), encrypted);
        self.save_store(store.entries)?;

        // Update cache while write_lock is held so cache and disk are always consistent.
        self.cache
            .write_recovered()?
            .insert(key.to_string(), value.to_string());

        debug!("Credential stored in encrypted file: {key}");
        Ok(())
    }

    fn get(&self, key: &str) -> Result<Option<String>> {
        // Check cache first (no write_lock needed for reads)
        {
            let cache = self.cache.read_recovered()?;
            if let Some(value) = cache.get(key) {
                return Ok(Some(value.clone()));
            }
        }

        let store = self.load_store()?;

        let Some(entry) = store.entries.get(key) else {
            return Ok(None);
        };

        let value = self.decrypt(entry)?;

        self.cache
            .write_recovered()?
            .insert(key.to_string(), value.clone());

        debug!("Credential retrieved from encrypted file: {key}");
        Ok(Some(value))
    }

    fn remove(&self, key: &str) -> Result<()> {
        let _guard = self
            .write_lock
            .lock()
            .map_err(|_| Error::Credential("Encrypted file write lock poisoned".into()))?;

        let mut store = self.load_store()?;
        store.entries.remove(key);
        self.save_store(store.entries)?;

        self.cache.write_recovered()?.remove(key);

        debug!("Credential removed from encrypted file: {key}");
        Ok(())
    }

    fn list_keys(&self) -> Result<Vec<String>> {
        Ok(self.load_store()?.entries.into_keys().collect())
    }

    fn backend_name(&self) -> &'static str {
        "encrypted_file"
    }
}

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_encrypted_store_and_get() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("credentials.enc.json");
        let salt = EncryptedFileBackend::generate_salt();
        let key = EncryptedFileBackend::generate_key();

        let backend = EncryptedFileBackend::new(path.clone(), &key, salt).unwrap();

        backend.store("api_key", "secret123").unwrap();
        backend.store("password", "hunter2").unwrap();

        // Create new instance to test persistence (must use same key and salt)
        let backend2 = EncryptedFileBackend::new(path, &key, salt).unwrap();

        assert_eq!(
            backend2.get("api_key").unwrap(),
            Some("secret123".to_string())
        );
        assert_eq!(
            backend2.get("password").unwrap(),
            Some("hunter2".to_string())
        );
    }

    #[test]
    fn test_encrypted_wrong_key() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("credentials.enc.json");
        let salt = EncryptedFileBackend::generate_salt();
        let key1 = EncryptedFileBackend::generate_key();
        let key2 = EncryptedFileBackend::generate_key();

        let backend1 = EncryptedFileBackend::new(path.clone(), &key1, salt).unwrap();
        backend1.store("secret", "value").unwrap();

        // Try to read with different key (same salt, simulating wrong password)
        let backend2 = EncryptedFileBackend::new(path, &key2, salt).unwrap();
        let result = backend2.get("secret");

        assert!(result.is_err());
    }

    #[test]
    fn test_with_password() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("credentials.enc.json");

        // Create with password (generates salt automatically)
        let backend = EncryptedFileBackend::with_password(path.clone(), "test_password").unwrap();
        backend.store("api_key", "secret123").unwrap();

        // Reopen with same password - should read salt from file
        let backend2 = EncryptedFileBackend::with_password(path.clone(), "test_password").unwrap();
        assert_eq!(
            backend2.get("api_key").unwrap(),
            Some("secret123".to_string())
        );

        // Wrong password should fail to decrypt
        let backend3 = EncryptedFileBackend::with_password(path, "wrong_password").unwrap();
        assert!(backend3.get("api_key").is_err());
    }

    #[test]
    fn test_derive_key() {
        let salt = EncryptedFileBackend::generate_salt();

        // Same password + same salt = same key
        let key1 = EncryptedFileBackend::derive_key("password123", &salt).unwrap();
        let key2 = EncryptedFileBackend::derive_key("password123", &salt).unwrap();
        assert_eq!(key1, key2);

        // Different password = different key
        let key3 = EncryptedFileBackend::derive_key("different", &salt).unwrap();
        assert_ne!(key1, key3);

        // Different salt = different key (even with same password)
        let salt2 = EncryptedFileBackend::generate_salt();
        let key4 = EncryptedFileBackend::derive_key("password123", &salt2).unwrap();
        assert_ne!(key1, key4);
    }

    /// Regression test for the concurrent write TOCTOU bug.
    /// Two threads writing different keys must both survive.
    #[test]
    fn test_concurrent_store_no_lost_writes() {
        use std::sync::Arc;
        use std::thread;

        let temp = tempdir().unwrap();
        let path = temp.path().join("credentials.enc.json");
        let backend = Arc::new(EncryptedFileBackend::with_password(path, "password").unwrap());

        let b1 = Arc::clone(&backend);
        let b2 = Arc::clone(&backend);

        let t1 = thread::spawn(move || b1.store("key_a", "value_a").unwrap());
        let t2 = thread::spawn(move || b2.store("key_b", "value_b").unwrap());

        t1.join().unwrap();
        t2.join().unwrap();

        assert_eq!(backend.get("key_a").unwrap(), Some("value_a".to_string()));
        assert_eq!(backend.get("key_b").unwrap(), Some("value_b".to_string()));
    }
}