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::sync::Mutex;
243
244    // Serialize keyring tests that touch the shared on-disk keyring file.
245    // Under nextest each test runs in its OWN PROCESS, so an in-process Mutex
246    // cannot stop concurrent processes from racing on the fixed
247    // `$XDG_CONFIG_HOME/reddb/keyring.enc` path (the source of the flaky
248    // `test_keyring_save_and_retrieve`). On first lock, lazily point
249    // XDG_CONFIG_HOME at a process-unique temp dir so every test process gets
250    // its own keyring file; the Mutex still serializes the in-process case for
251    // a plain `cargo test` run.
252    static KEYRING_TEST_LOCK: std::sync::LazyLock<Mutex<()>> = std::sync::LazyLock::new(|| {
253        let dir = std::env::temp_dir().join(format!("reddb-keyring-test-{}", std::process::id()));
254        let _ = std::fs::create_dir_all(&dir);
255        std::env::set_var("XDG_CONFIG_HOME", dir);
256        Mutex::new(())
257    });
258
259    #[test]
260    fn test_password_source_is_encrypted() {
261        assert!(PasswordSource::Flag("test".to_string()).is_encrypted());
262        assert!(PasswordSource::EnvVar("test".to_string()).is_encrypted());
263        assert!(PasswordSource::Keyring("test".to_string()).is_encrypted());
264        assert!(!PasswordSource::None.is_encrypted());
265    }
266
267    #[test]
268    fn test_password_source_name() {
269        assert_eq!(PasswordSource::Flag("".to_string()).source_name(), "flag");
270        assert_eq!(PasswordSource::EnvVar("".to_string()).source_name(), "env");
271        assert_eq!(
272            PasswordSource::Keyring("".to_string()).source_name(),
273            "keyring"
274        );
275        assert_eq!(PasswordSource::None.source_name(), "none");
276    }
277
278    #[test]
279    fn test_password_source_password() {
280        assert_eq!(
281            PasswordSource::Flag("mypass".to_string()).password(),
282            Some("mypass")
283        );
284        assert_eq!(
285            PasswordSource::EnvVar("envpass".to_string()).password(),
286            Some("envpass")
287        );
288        assert_eq!(
289            PasswordSource::Keyring("ringpass".to_string()).password(),
290            Some("ringpass")
291        );
292        assert_eq!(PasswordSource::None.password(), None);
293    }
294
295    #[test]
296    fn test_derive_keyring_key_deterministic() {
297        let key1 = derive_keyring_key();
298        let key2 = derive_keyring_key();
299        assert_eq!(key1, key2);
300        assert_eq!(key1.len(), 32);
301    }
302
303    #[test]
304    fn test_derive_keyring_key_length() {
305        let key = derive_keyring_key();
306        assert_eq!(key.len(), 32); // AES-256 key
307    }
308
309    #[test]
310    fn test_generate_nonce_uniqueness() {
311        let nonce1 = generate_nonce();
312        let nonce2 = generate_nonce();
313        assert_ne!(nonce1, nonce2);
314        assert_eq!(nonce1.len(), 12);
315        assert_eq!(nonce2.len(), 12);
316    }
317
318    #[test]
319    fn test_resolve_password_flag_priority() {
320        // Flag should have highest priority
321        let result = resolve_password(Some("flag_password"));
322        assert!(matches!(result, PasswordSource::Flag(_)));
323        if let PasswordSource::Flag(pwd) = result {
324            assert_eq!(pwd, "flag_password");
325        }
326    }
327
328    #[test]
329    fn test_resolve_password_empty_flag() {
330        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
331        std::env::remove_var("REDDB_KEY");
332        let _ = clear_keyring();
333
334        // Empty flag should not be used
335        let result = resolve_password(Some(""));
336        assert!(!matches!(result, PasswordSource::Flag(_)));
337    }
338
339    #[test]
340    fn test_resolve_password_env_var() {
341        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
342        let _ = clear_keyring();
343
344        std::env::set_var("REDDB_KEY", "env_test_password");
345        let result = resolve_password(None);
346        std::env::remove_var("REDDB_KEY");
347
348        assert!(matches!(result, PasswordSource::EnvVar(_)));
349        if let PasswordSource::EnvVar(pwd) = result {
350            assert_eq!(pwd, "env_test_password");
351        }
352    }
353
354    #[test]
355    fn test_resolve_password_flag_overrides_env() {
356        std::env::set_var("REDDB_KEY", "env_password");
357        let result = resolve_password(Some("flag_password"));
358        std::env::remove_var("REDDB_KEY");
359
360        assert!(matches!(result, PasswordSource::Flag(_)));
361    }
362
363    #[test]
364    fn test_keyring_save_and_retrieve() {
365        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
366
367        // Clear any existing keyring
368        let _ = clear_keyring();
369
370        // Save password
371        let result = save_to_keyring("test_keyring_password_12345");
372        assert!(result.is_ok(), "Failed to save to keyring: {:?}", result);
373
374        // Retrieve password
375        let retrieved = get_from_keyring();
376        assert!(retrieved.is_some());
377        assert_eq!(retrieved.unwrap(), "test_keyring_password_12345");
378
379        // Clean up
380        let _ = clear_keyring();
381    }
382
383    #[test]
384    fn test_keyring_has_password() {
385        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
386
387        let _ = clear_keyring();
388        assert!(!has_keyring_password());
389
390        let _ = save_to_keyring("check_password");
391        assert!(has_keyring_password());
392
393        let _ = clear_keyring();
394        assert!(!has_keyring_password());
395    }
396
397    #[test]
398    fn test_clear_keyring_nonexistent() {
399        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
400
401        // Should not error if keyring doesn't exist
402        let _ = clear_keyring();
403        let result = clear_keyring();
404        assert!(result.is_ok());
405    }
406
407    #[test]
408    fn test_keyring_special_characters() {
409        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
410        let _ = clear_keyring();
411
412        // Test password with special characters
413        let special_password = "p@$$w0rd!#%&*()[]{}|;':\",./<>?`~";
414        let result = save_to_keyring(special_password);
415        assert!(result.is_ok());
416
417        let retrieved = get_from_keyring();
418        assert_eq!(retrieved, Some(special_password.to_string()));
419
420        let _ = clear_keyring();
421    }
422
423    #[test]
424    fn test_keyring_unicode_password() {
425        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
426        let _ = clear_keyring();
427
428        // Test password with unicode characters
429        let unicode_password = "пароль🔒密码パスワード";
430        let result = save_to_keyring(unicode_password);
431        assert!(result.is_ok());
432
433        let retrieved = get_from_keyring();
434        assert_eq!(retrieved, Some(unicode_password.to_string()));
435
436        let _ = clear_keyring();
437    }
438
439    #[test]
440    fn test_keyring_empty_password() {
441        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
442        let _ = clear_keyring();
443
444        // Even empty password should work
445        let result = save_to_keyring("");
446        assert!(result.is_ok());
447
448        let retrieved = get_from_keyring();
449        assert_eq!(retrieved, Some("".to_string()));
450
451        let _ = clear_keyring();
452    }
453
454    #[test]
455    fn test_keyring_long_password() {
456        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
457        let _ = clear_keyring();
458
459        // Test very long password
460        let long_password = "x".repeat(10000);
461        let result = save_to_keyring(&long_password);
462        assert!(result.is_ok());
463
464        let retrieved = get_from_keyring();
465        assert_eq!(retrieved, Some(long_password));
466
467        let _ = clear_keyring();
468    }
469
470    #[test]
471    fn test_resolve_password_keyring_integration() {
472        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
473        std::env::remove_var("REDDB_KEY");
474        let _ = clear_keyring();
475
476        // Save to keyring
477        let _ = save_to_keyring("keyring_test_pwd");
478
479        // Should resolve from keyring
480        let result = resolve_password(None);
481        assert!(matches!(result, PasswordSource::Keyring(_)));
482        if let PasswordSource::Keyring(pwd) = result {
483            assert_eq!(pwd, "keyring_test_pwd");
484        }
485
486        let _ = clear_keyring();
487    }
488
489    #[test]
490    fn test_resolve_password_none_when_empty() {
491        let _lock = KEYRING_TEST_LOCK.lock().unwrap();
492        std::env::remove_var("REDDB_KEY");
493        let _ = clear_keyring();
494
495        let result = resolve_password(None);
496        assert!(matches!(result, PasswordSource::None));
497    }
498
499    #[test]
500    fn test_get_keyring_path_returns_some() {
501        // Should return a path on most systems
502        let path = get_keyring_path();
503        // This might be None in very restricted environments
504        if let Some(p) = path {
505            assert!(p.to_string_lossy().contains("reddb"));
506            assert!(p.to_string_lossy().contains("keyring.enc"));
507        }
508    }
509}