whiteout/storage/
crypto.rs1use 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>, }
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 let salt = SaltString::generate(&mut rand::thread_rng());
40 let salt_str = salt.to_string();
41
42 if let Some(parent) = salt_path.parent() {
44 fs::create_dir_all(parent)?;
45 }
46
47 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); fs::set_permissions(&salt_path, perms)?;
56 }
57
58 Ok(salt_str)
59 }
60 }
61
62 fn salt_path() -> Result<PathBuf> {
63 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 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 assert!(crypto2.decrypt(&encrypted).is_err());
157
158 Ok(())
159 }
160
161 #[test]
162 fn test_salt_persistence() -> Result<()> {
163 let crypto1 = Crypto::new("test-pass")?;
165 let salt1 = crypto1.salt.clone();
166
167 let crypto2 = Crypto::new("test-pass")?;
169 let salt2 = crypto2.salt.clone();
170
171 assert_eq!(salt1, salt2);
172
173 Ok(())
174 }
175}