whiteout/storage/
crypto.rs

1use aes_gcm::{
2    aead::{Aead, KeyInit, OsRng},
3    Aes256Gcm, Key, Nonce,
4};
5use anyhow::Result;
6use argon2::{
7    password_hash::SaltString,
8    Argon2,
9};
10use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
11use std::fs;
12use std::path::PathBuf;
13
14pub struct Crypto {
15    cipher: Aes256Gcm,
16    #[allow(dead_code)]
17    salt: Option<String>,  // Stored for potential future use
18}
19
20impl Crypto {
21    pub fn new(passphrase: &str) -> Result<Self> {
22        let salt = Self::get_or_create_salt()?;
23        let key = Self::derive_key(passphrase, &salt)?;
24        let cipher = Aes256Gcm::new(&key);
25        Ok(Self { 
26            cipher, 
27            salt: Some(salt),
28        })
29    }
30    
31    fn get_or_create_salt() -> Result<String> {
32        let salt_path = Self::salt_path()?;
33        
34        if salt_path.exists() {
35            fs::read_to_string(&salt_path)
36                .map_err(|e| anyhow::anyhow!("Failed to read salt file: {}", e))
37        } else {
38            // Generate new random salt
39            let salt = SaltString::generate(&mut rand::thread_rng());
40            let salt_str = salt.to_string();
41            
42            // Create directory if needed
43            if let Some(parent) = salt_path.parent() {
44                fs::create_dir_all(parent)?;
45            }
46            
47            // Save salt with restricted permissions
48            fs::write(&salt_path, &salt_str)?;
49            
50            #[cfg(unix)]
51            {
52                use std::os::unix::fs::PermissionsExt;
53                let mut perms = fs::metadata(&salt_path)?.permissions();
54                perms.set_mode(0o600); // Read/write for owner only
55                fs::set_permissions(&salt_path, perms)?;
56            }
57            
58            Ok(salt_str)
59        }
60    }
61    
62    fn salt_path() -> Result<PathBuf> {
63        // Try to use project-local .whiteout directory first
64        let local_path = PathBuf::from(".whiteout/.salt");
65        if local_path.parent().map_or(false, |p| p.exists()) {
66            return Ok(local_path);
67        }
68        
69        // Fallback to user config directory
70        directories::ProjectDirs::from("dev", "whiteout", "whiteout")
71            .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))
72            .map(|dirs| dirs.config_dir().join(".salt"))
73    }
74
75    pub fn encrypt(&self, plaintext: &str) -> Result<String> {
76        use aes_gcm::aead::rand_core::RngCore;
77        
78        let mut nonce_bytes = [0u8; 12];
79        OsRng.fill_bytes(&mut nonce_bytes);
80        let nonce = Nonce::from_slice(&nonce_bytes);
81        
82        let ciphertext = self
83            .cipher
84            .encrypt(nonce, plaintext.as_bytes())
85            .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
86        
87        let mut combined = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
88        combined.extend_from_slice(&nonce_bytes);
89        combined.extend_from_slice(&ciphertext);
90        
91        Ok(BASE64.encode(combined))
92    }
93
94    pub fn decrypt(&self, encrypted: &str) -> Result<String> {
95        let combined = BASE64
96            .decode(encrypted)
97            .map_err(|e| anyhow::anyhow!("Failed to decode base64: {}", e))?;
98        
99        if combined.len() < 12 {
100            anyhow::bail!("Invalid encrypted data");
101        }
102        
103        let (nonce_bytes, ciphertext) = combined.split_at(12);
104        let nonce = Nonce::from_slice(nonce_bytes);
105        
106        let plaintext = self
107            .cipher
108            .decrypt(nonce, ciphertext)
109            .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?;
110        
111        String::from_utf8(plaintext)
112            .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted data: {}", e))
113    }
114
115    fn derive_key(passphrase: &str, salt_str: &str) -> Result<Key<Aes256Gcm>> {
116        let argon2 = Argon2::default();
117        let salt = SaltString::from_b64(salt_str)
118            .map_err(|e| anyhow::anyhow!("Invalid salt format: {}", e))?;
119        
120        let mut output = [0u8; 32];
121        argon2
122            .hash_password_into(passphrase.as_bytes(), salt.as_str().as_bytes(), &mut output)
123            .map_err(|e| anyhow::anyhow!("Failed to derive key: {}", e))?;
124        
125        Ok(*Key::<Aes256Gcm>::from_slice(&output))
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_encrypt_decrypt() -> Result<()> {
135        let crypto = Crypto::new("test-passphrase")?;
136        let plaintext = "secret data";
137        
138        let encrypted = crypto.encrypt(plaintext)?;
139        assert_ne!(encrypted, plaintext);
140        
141        let decrypted = crypto.decrypt(&encrypted)?;
142        assert_eq!(decrypted, plaintext);
143        
144        Ok(())
145    }
146
147    #[test]
148    fn test_different_passphrases() -> Result<()> {
149        let crypto1 = Crypto::new("passphrase1")?;
150        let crypto2 = Crypto::new("passphrase2")?;
151        
152        let plaintext = "secret data";
153        let encrypted = crypto1.encrypt(plaintext)?;
154        
155        // Different passphrases with same salt should produce different keys
156        assert!(crypto2.decrypt(&encrypted).is_err());
157        
158        Ok(())
159    }
160    
161    #[test]
162    fn test_salt_persistence() -> Result<()> {
163        // First instance creates salt
164        let crypto1 = Crypto::new("test-pass")?;
165        let salt1 = crypto1.salt.clone();
166        
167        // Second instance should reuse same salt
168        let crypto2 = Crypto::new("test-pass")?;
169        let salt2 = crypto2.salt.clone();
170        
171        assert_eq!(salt1, salt2);
172        
173        Ok(())
174    }
175}