Skip to main content

reddb_server/storage/
keyring.rs

1//! Keyring integration for secure password storage
2//!
3//! Stores the database encryption password in the system keyring
4//! so users don't need to enter it every time.
5//!
6//! On Linux: Uses ~/.config/reddb/keyring (encrypted with user-specific key)
7//! On macOS: Uses Keychain when available (TBD)
8//! On Windows: Uses Credential Manager when available (TBD)
9
10use std::fs;
11use std::io::{Read, Write};
12use std::path::PathBuf;
13
14use crate::crypto::aes_gcm::{aes256_gcm_decrypt, aes256_gcm_encrypt};
15use crate::crypto::sha256::sha256;
16use crate::crypto::uuid::Uuid;
17
18const SERVICE_NAME: &str = "reddb";
19const KEYRING_FILE: &str = "keyring.enc";
20
21/// Result of password resolution
22#[derive(Debug, Clone)]
23pub enum PasswordSource {
24    /// Password from --db-password flag
25    Flag(String),
26    /// Password from REDDB_KEY environment variable
27    EnvVar(String),
28    /// Password from system keyring
29    Keyring(String),
30    /// No password configured - database will be unencrypted
31    None,
32}
33
34impl PasswordSource {
35    pub fn password(&self) -> Option<&str> {
36        match self {
37            PasswordSource::Flag(p) => Some(p),
38            PasswordSource::EnvVar(p) => Some(p),
39            PasswordSource::Keyring(p) => Some(p),
40            PasswordSource::None => None,
41        }
42    }
43
44    pub fn is_encrypted(&self) -> bool {
45        !matches!(self, PasswordSource::None)
46    }
47
48    pub fn source_name(&self) -> &'static str {
49        match self {
50            PasswordSource::Flag(_) => "flag",
51            PasswordSource::EnvVar(_) => "env",
52            PasswordSource::Keyring(_) => "keyring",
53            PasswordSource::None => "none",
54        }
55    }
56}
57
58/// Resolve password from multiple sources (priority order)
59/// 1. --db-password flag (highest priority)
60/// 2. REDDB_KEY environment variable
61/// 3. System keyring
62/// 4. None (no encryption)
63pub fn resolve_password(flag_password: Option<&str>) -> PasswordSource {
64    // Priority 1: Explicit flag
65    if let Some(pwd) = flag_password {
66        if !pwd.is_empty() {
67            return PasswordSource::Flag(pwd.to_string());
68        }
69    }
70
71    // Priority 2: Environment variable (try REDDB_KEY first, fall back to REDBLUE_DB_KEY)
72    if let Some(pwd) = crate::utils::env_with_file_fallback("REDDB_KEY")
73        .or_else(|| crate::utils::env_with_file_fallback("REDBLUE_DB_KEY"))
74    {
75        return PasswordSource::EnvVar(pwd);
76    }
77
78    // Priority 3: System keyring
79    if let Some(pwd) = get_from_keyring() {
80        return PasswordSource::Keyring(pwd);
81    }
82
83    // Priority 4: No password
84    PasswordSource::None
85}
86
87/// Get password from system keyring
88pub fn get_from_keyring() -> Option<String> {
89    let keyring_path = get_keyring_path()?;
90
91    if !keyring_path.exists() {
92        return None;
93    }
94
95    let mut file = fs::File::open(&keyring_path).ok()?;
96    let mut encrypted_data = Vec::new();
97    file.read_to_end(&mut encrypted_data).ok()?;
98
99    if encrypted_data.len() < 28 {
100        // Minimum: 12 (nonce) + 16 (tag)
101        return None;
102    }
103
104    let key = derive_keyring_key();
105    let nonce: [u8; 12] = encrypted_data[..12].try_into().ok()?;
106    let ciphertext_and_tag = &encrypted_data[12..];
107
108    let plaintext = aes256_gcm_decrypt(&key, &nonce, &[], ciphertext_and_tag).ok()?;
109
110    String::from_utf8(plaintext).ok()
111}
112
113/// Save password to system keyring
114pub fn save_to_keyring(password: &str) -> Result<(), String> {
115    let keyring_path = get_keyring_path().ok_or("Failed to determine keyring path")?;
116
117    // Ensure parent directory exists
118    if let Some(parent) = keyring_path.parent() {
119        fs::create_dir_all(parent)
120            .map_err(|e| format!("Failed to create keyring directory: {}", e))?;
121    }
122
123    let key = derive_keyring_key();
124
125    // Generate random nonce
126    let nonce = generate_nonce();
127
128    // Encrypt password
129    let ciphertext_and_tag = aes256_gcm_encrypt(&key, &nonce, &[], password.as_bytes());
130
131    // Write: nonce || ciphertext || tag
132    let mut data = Vec::with_capacity(12 + ciphertext_and_tag.len());
133    data.extend_from_slice(&nonce);
134    data.extend_from_slice(&ciphertext_and_tag);
135
136    let mut file = fs::File::create(&keyring_path)
137        .map_err(|e| format!("Failed to create keyring file: {}", e))?;
138    file.write_all(&data)
139        .map_err(|e| format!("Failed to write keyring: {}", e))?;
140
141    // Set restrictive permissions on Unix
142    #[cfg(unix)]
143    {
144        use std::os::unix::fs::PermissionsExt;
145        let permissions = std::fs::Permissions::from_mode(0o600);
146        fs::set_permissions(&keyring_path, permissions)
147            .map_err(|e| format!("Failed to set keyring permissions: {}", e))?;
148    }
149
150    Ok(())
151}
152
153/// Remove password from system keyring
154pub fn clear_keyring() -> Result<(), String> {
155    let keyring_path = get_keyring_path().ok_or("Failed to determine keyring path")?;
156
157    if keyring_path.exists() {
158        fs::remove_file(&keyring_path).map_err(|e| format!("Failed to remove keyring: {}", e))?;
159    }
160
161    Ok(())
162}
163
164/// Check if keyring has a stored password
165pub fn has_keyring_password() -> bool {
166    get_from_keyring().is_some()
167}
168
169/// Get keyring file path
170fn get_keyring_path() -> Option<PathBuf> {
171    // Try XDG config directory first
172    if let Ok(config_dir) = std::env::var("XDG_CONFIG_HOME") {
173        return Some(
174            PathBuf::from(config_dir)
175                .join(SERVICE_NAME)
176                .join(KEYRING_FILE),
177        );
178    }
179
180    // Fall back to ~/.config
181    if let Ok(home) = std::env::var("HOME") {
182        return Some(
183            PathBuf::from(home)
184                .join(".config")
185                .join(SERVICE_NAME)
186                .join(KEYRING_FILE),
187        );
188    }
189
190    // Windows fallback
191    if let Ok(appdata) = std::env::var("APPDATA") {
192        return Some(PathBuf::from(appdata).join(SERVICE_NAME).join(KEYRING_FILE));
193    }
194
195    None
196}
197
198/// Derive a unique key for keyring encryption based on machine/user identity
199fn derive_keyring_key() -> [u8; 32] {
200    let mut identity = String::new();
201
202    // Add hostname
203    if let Ok(hostname) = std::env::var("HOSTNAME") {
204        identity.push_str(&hostname);
205    } else if let Ok(name) = std::env::var("COMPUTERNAME") {
206        identity.push_str(&name);
207    }
208    identity.push(':');
209
210    // Add username
211    if let Ok(user) = std::env::var("USER") {
212        identity.push_str(&user);
213    } else if let Ok(user) = std::env::var("USERNAME") {
214        identity.push_str(&user);
215    }
216    identity.push(':');
217
218    // Add home directory as additional entropy
219    if let Ok(home) = std::env::var("HOME") {
220        identity.push_str(&home);
221    } else if let Ok(home) = std::env::var("USERPROFILE") {
222        identity.push_str(&home);
223    }
224
225    // Add a fixed salt for the keyring
226    identity.push_str(":reddb-keyring-v1");
227
228    sha256(identity.as_bytes())
229}
230
231/// Generate a random 12-byte nonce
232fn generate_nonce() -> [u8; 12] {
233    let uuid = Uuid::new_v4();
234    let mut nonce = [0u8; 12];
235    nonce.copy_from_slice(&uuid.as_bytes()[0..12]);
236    nonce
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::ffi::OsString;
243    use std::path::PathBuf;
244    use std::sync::{Mutex, MutexGuard};
245
246    // Serialize keyring tests that touch the shared on-disk keyring file.
247    // Under nextest each test runs in its OWN PROCESS, so an in-process Mutex
248    // cannot stop concurrent processes from racing on the fixed
249    // `$XDG_CONFIG_HOME/reddb/keyring.enc` path (the source of the flaky
250    // `test_keyring_save_and_retrieve`). The Mutex still serializes the
251    // in-process case for a plain `cargo test` run; the guard also points
252    // XDG_CONFIG_HOME at a removable temp dir for the current test.
253    static KEYRING_TEST_LOCK: std::sync::LazyLock<Mutex<()>> =
254        std::sync::LazyLock::new(|| Mutex::new(()));
255
256    struct KeyringTestGuard {
257        _lock: MutexGuard<'static, ()>,
258        dir: PathBuf,
259        previous_config_home: Option<OsString>,
260    }
261
262    impl Drop for KeyringTestGuard {
263        fn drop(&mut self) {
264            if let Some(value) = &self.previous_config_home {
265                std::env::set_var("XDG_CONFIG_HOME", value);
266            } else {
267                std::env::remove_var("XDG_CONFIG_HOME");
268            }
269            let _ = std::fs::remove_dir_all(&self.dir);
270        }
271    }
272
273    fn keyring_test_guard() -> KeyringTestGuard {
274        let lock = KEYRING_TEST_LOCK.lock().unwrap();
275        let dir = std::env::temp_dir().join(format!(
276            "reddb-keyring-test-{}-{}",
277            std::process::id(),
278            crate::utils::now_unix_nanos()
279        ));
280        std::fs::create_dir_all(&dir).unwrap();
281        let previous_config_home = std::env::var_os("XDG_CONFIG_HOME");
282        std::env::set_var("XDG_CONFIG_HOME", &dir);
283        KeyringTestGuard {
284            _lock: lock,
285            dir,
286            previous_config_home,
287        }
288    }
289
290    #[test]
291    fn test_password_source_is_encrypted() {
292        assert!(PasswordSource::Flag("test".to_string()).is_encrypted());
293        assert!(PasswordSource::EnvVar("test".to_string()).is_encrypted());
294        assert!(PasswordSource::Keyring("test".to_string()).is_encrypted());
295        assert!(!PasswordSource::None.is_encrypted());
296    }
297
298    #[test]
299    fn test_password_source_name() {
300        assert_eq!(PasswordSource::Flag("".to_string()).source_name(), "flag");
301        assert_eq!(PasswordSource::EnvVar("".to_string()).source_name(), "env");
302        assert_eq!(
303            PasswordSource::Keyring("".to_string()).source_name(),
304            "keyring"
305        );
306        assert_eq!(PasswordSource::None.source_name(), "none");
307    }
308
309    #[test]
310    fn test_password_source_password() {
311        assert_eq!(
312            PasswordSource::Flag("mypass".to_string()).password(),
313            Some("mypass")
314        );
315        assert_eq!(
316            PasswordSource::EnvVar("envpass".to_string()).password(),
317            Some("envpass")
318        );
319        assert_eq!(
320            PasswordSource::Keyring("ringpass".to_string()).password(),
321            Some("ringpass")
322        );
323        assert_eq!(PasswordSource::None.password(), None);
324    }
325
326    #[test]
327    fn test_derive_keyring_key_deterministic() {
328        let key1 = derive_keyring_key();
329        let key2 = derive_keyring_key();
330        assert_eq!(key1, key2);
331        assert_eq!(key1.len(), 32);
332    }
333
334    #[test]
335    fn test_derive_keyring_key_length() {
336        let key = derive_keyring_key();
337        assert_eq!(key.len(), 32); // AES-256 key
338    }
339
340    #[test]
341    fn test_generate_nonce_uniqueness() {
342        let nonce1 = generate_nonce();
343        let nonce2 = generate_nonce();
344        assert_ne!(nonce1, nonce2);
345        assert_eq!(nonce1.len(), 12);
346        assert_eq!(nonce2.len(), 12);
347    }
348
349    #[test]
350    fn test_resolve_password_flag_priority() {
351        // Flag should have highest priority
352        let result = resolve_password(Some("flag_password"));
353        assert!(matches!(result, PasswordSource::Flag(_)));
354        if let PasswordSource::Flag(pwd) = result {
355            assert_eq!(pwd, "flag_password");
356        }
357    }
358
359    #[test]
360    fn test_resolve_password_empty_flag() {
361        let _guard = keyring_test_guard();
362        std::env::remove_var("REDDB_KEY");
363        let _ = clear_keyring();
364
365        // Empty flag should not be used
366        let result = resolve_password(Some(""));
367        assert!(!matches!(result, PasswordSource::Flag(_)));
368    }
369
370    #[test]
371    fn test_resolve_password_env_var() {
372        let _guard = keyring_test_guard();
373        let _ = clear_keyring();
374
375        std::env::set_var("REDDB_KEY", "env_test_password");
376        let result = resolve_password(None);
377        std::env::remove_var("REDDB_KEY");
378
379        assert!(matches!(result, PasswordSource::EnvVar(_)));
380        if let PasswordSource::EnvVar(pwd) = result {
381            assert_eq!(pwd, "env_test_password");
382        }
383    }
384
385    #[test]
386    fn test_resolve_password_flag_overrides_env() {
387        std::env::set_var("REDDB_KEY", "env_password");
388        let result = resolve_password(Some("flag_password"));
389        std::env::remove_var("REDDB_KEY");
390
391        assert!(matches!(result, PasswordSource::Flag(_)));
392    }
393
394    #[test]
395    fn test_keyring_save_and_retrieve() {
396        let _guard = keyring_test_guard();
397
398        // Clear any existing keyring
399        let _ = clear_keyring();
400
401        // Save password
402        let result = save_to_keyring("test_keyring_password_12345");
403        assert!(result.is_ok(), "Failed to save to keyring: {:?}", result);
404
405        // Retrieve password
406        let retrieved = get_from_keyring();
407        assert!(retrieved.is_some());
408        assert_eq!(retrieved.unwrap(), "test_keyring_password_12345");
409
410        // Clean up
411        let _ = clear_keyring();
412    }
413
414    #[test]
415    fn test_keyring_has_password() {
416        let _guard = keyring_test_guard();
417
418        let _ = clear_keyring();
419        assert!(!has_keyring_password());
420
421        let _ = save_to_keyring("check_password");
422        assert!(has_keyring_password());
423
424        let _ = clear_keyring();
425        assert!(!has_keyring_password());
426    }
427
428    #[test]
429    fn test_clear_keyring_nonexistent() {
430        let _guard = keyring_test_guard();
431
432        // Should not error if keyring doesn't exist
433        let _ = clear_keyring();
434        let result = clear_keyring();
435        assert!(result.is_ok());
436    }
437
438    #[test]
439    fn test_keyring_special_characters() {
440        let _guard = keyring_test_guard();
441        let _ = clear_keyring();
442
443        // Test password with special characters
444        let special_password = "p@$$w0rd!#%&*()[]{}|;':\",./<>?`~";
445        let result = save_to_keyring(special_password);
446        assert!(result.is_ok());
447
448        let retrieved = get_from_keyring();
449        assert_eq!(retrieved, Some(special_password.to_string()));
450
451        let _ = clear_keyring();
452    }
453
454    #[test]
455    fn test_keyring_unicode_password() {
456        let _guard = keyring_test_guard();
457        let _ = clear_keyring();
458
459        // Test password with unicode characters
460        let unicode_password = "пароль🔒密码パスワード";
461        let result = save_to_keyring(unicode_password);
462        assert!(result.is_ok());
463
464        let retrieved = get_from_keyring();
465        assert_eq!(retrieved, Some(unicode_password.to_string()));
466
467        let _ = clear_keyring();
468    }
469
470    #[test]
471    fn test_keyring_empty_password() {
472        let _guard = keyring_test_guard();
473        let _ = clear_keyring();
474
475        // Even empty password should work
476        let result = save_to_keyring("");
477        assert!(result.is_ok());
478
479        let retrieved = get_from_keyring();
480        assert_eq!(retrieved, Some("".to_string()));
481
482        let _ = clear_keyring();
483    }
484
485    #[test]
486    fn test_keyring_long_password() {
487        let _guard = keyring_test_guard();
488        let _ = clear_keyring();
489
490        // Test very long password
491        let long_password = "x".repeat(10000);
492        let result = save_to_keyring(&long_password);
493        assert!(result.is_ok());
494
495        let retrieved = get_from_keyring();
496        assert_eq!(retrieved, Some(long_password));
497
498        let _ = clear_keyring();
499    }
500
501    #[test]
502    fn test_resolve_password_keyring_integration() {
503        let _guard = keyring_test_guard();
504        std::env::remove_var("REDDB_KEY");
505        let _ = clear_keyring();
506
507        // Save to keyring
508        let _ = save_to_keyring("keyring_test_pwd");
509
510        // Should resolve from keyring
511        let result = resolve_password(None);
512        assert!(matches!(result, PasswordSource::Keyring(_)));
513        if let PasswordSource::Keyring(pwd) = result {
514            assert_eq!(pwd, "keyring_test_pwd");
515        }
516
517        let _ = clear_keyring();
518    }
519
520    #[test]
521    fn test_resolve_password_none_when_empty() {
522        let _guard = keyring_test_guard();
523        std::env::remove_var("REDDB_KEY");
524        let _ = clear_keyring();
525
526        let result = resolve_password(None);
527        assert!(matches!(result, PasswordSource::None));
528    }
529
530    #[test]
531    fn test_get_keyring_path_returns_some() {
532        // Should return a path on most systems
533        let path = get_keyring_path();
534        // This might be None in very restricted environments
535        if let Some(p) = path {
536            assert!(p.to_string_lossy().contains("reddb"));
537            assert!(p.to_string_lossy().contains("keyring.enc"));
538        }
539    }
540}