tryaudex_core/
keystore.rs1use aes_gcm::{
2 aead::{Aead, KeyInit},
3 Aes256Gcm, Nonce,
4};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{AvError, Result};
8
9const KEYRING_SERVICE: &str = "audex";
10const KEYRING_USER: &str = "credential-encryption-key";
11const NONCE_LEN: usize = 12;
12
13static CACHED_KEY: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new();
14
15#[derive(Debug, Serialize, Deserialize)]
17pub struct EncryptedBlob {
18 pub nonce: String,
20 pub ciphertext: String,
22}
23
24pub(crate) fn get_or_create_key() -> [u8; 32] {
28 *CACHED_KEY.get_or_init(|| {
29 if let Ok(key) = load_key_from_keychain() {
31 return key;
32 }
33
34 if let Ok(key) = generate_and_store_key() {
36 return key;
37 }
38
39 derive_fallback_key()
41 })
42}
43
44fn load_key_from_keychain() -> std::result::Result<[u8; 32], ()> {
45 let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|_| ())?;
46 let secret = entry.get_password().map_err(|_| ())?;
47 use base64::Engine;
48 let bytes = base64::engine::general_purpose::STANDARD
49 .decode(&secret)
50 .map_err(|_| ())?;
51 if bytes.len() != 32 {
52 return Err(());
53 }
54 let mut key = [0u8; 32];
55 key.copy_from_slice(&bytes);
56 Ok(key)
57}
58
59fn generate_and_store_key() -> std::result::Result<[u8; 32], ()> {
60 use rand::Rng;
61 let mut key = [0u8; 32];
62 rand::rng().fill(&mut key);
63
64 use base64::Engine;
65 let encoded = base64::engine::general_purpose::STANDARD.encode(key);
66
67 let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|_| ())?;
68 entry.set_password(&encoded).map_err(|_| ())?;
69
70 Ok(key)
71}
72
73fn derive_fallback_key() -> [u8; 32] {
91 use sha2::Digest;
92
93 eprintln!(
94 " \x1b[33m⚠\x1b[0m OS keychain unavailable — using fallback encryption key. \
95 Strength depends on ~/.config/audex/keystore-salt being private."
96 );
97
98 let salt = load_or_create_salt().unwrap_or_else(|| {
99 panic!(
109 "audex: keystore salt file could not be created \
110 (~/.config/audex/keystore-salt). Proceeding without it would \
111 derive the encryption key from public inputs only, which any \
112 local user could recompute. Set the AUDEX_HMAC_KEY environment \
113 variable to provide an explicit key, or ensure the audex config \
114 directory is writable."
115 );
116 });
117
118 let mut hasher = sha2::Sha256::new();
119 hasher.update(b"audex-fallback-key-v2");
120
121 hasher.update(&salt);
123
124 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("LOGNAME")) {
126 hasher.update(user.as_bytes());
127 }
128
129 if let Some(home) = dirs::home_dir() {
131 hasher.update(home.to_string_lossy().as_bytes());
132 }
133
134 if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") {
136 hasher.update(machine_id.trim().as_bytes());
137 }
138
139 hasher.finalize().into()
140}
141
142fn load_or_create_salt() -> Option<Vec<u8>> {
144 let dir = dirs::config_dir()?.join("audex");
145 let path = dir.join("keystore-salt");
146
147 if let Ok(data) = std::fs::read(&path) {
149 if data.len() == 32 {
150 return Some(data);
151 }
152 }
153
154 use rand::Rng;
156 let mut salt = [0u8; 32];
157 rand::rng().fill(&mut salt);
158
159 let _ = std::fs::create_dir_all(&dir);
161 #[cfg(unix)]
162 {
163 use std::os::unix::fs::PermissionsExt;
164 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
165 }
166 #[cfg(unix)]
167 let write_ok = {
168 use std::io::Write;
169 use std::os::unix::fs::OpenOptionsExt;
170 match std::fs::OpenOptions::new()
171 .write(true)
172 .create_new(true)
173 .mode(0o600)
174 .open(&path)
175 {
176 Ok(mut file) => file.write_all(&salt).is_ok(),
177 Err(_) => false,
178 }
179 };
180 #[cfg(not(unix))]
181 let write_ok = std::fs::write(&path, salt).is_ok();
182
183 if write_ok {
184 Some(salt.to_vec())
185 } else {
186 tracing::warn!("Failed to write keystore salt file; fallback key has reduced entropy");
187 None
188 }
189}
190
191pub fn encrypt(plaintext: &[u8]) -> Result<EncryptedBlob> {
193 let key = get_or_create_key();
194 let cipher = Aes256Gcm::new_from_slice(&key)
195 .map_err(|e| AvError::InvalidPolicy(format!("Encryption init failed: {}", e)))?;
196
197 use rand::Rng;
198 let mut nonce_bytes = [0u8; NONCE_LEN];
199 rand::rng().fill(&mut nonce_bytes);
200 let nonce = Nonce::from_slice(&nonce_bytes);
201
202 let ciphertext = cipher
203 .encrypt(nonce, plaintext)
204 .map_err(|e| AvError::InvalidPolicy(format!("Encryption failed: {}", e)))?;
205
206 use base64::Engine;
207 Ok(EncryptedBlob {
208 nonce: base64::engine::general_purpose::STANDARD.encode(nonce_bytes),
209 ciphertext: base64::engine::general_purpose::STANDARD.encode(ciphertext),
210 })
211}
212
213pub fn decrypt(blob: &EncryptedBlob) -> Result<Vec<u8>> {
215 let key = get_or_create_key();
216 let cipher = Aes256Gcm::new_from_slice(&key)
217 .map_err(|e| AvError::InvalidPolicy(format!("Decryption init failed: {}", e)))?;
218
219 use base64::Engine;
220 let nonce_bytes = base64::engine::general_purpose::STANDARD
221 .decode(&blob.nonce)
222 .map_err(|e| AvError::InvalidPolicy(format!("Invalid nonce: {}", e)))?;
223 let ciphertext = base64::engine::general_purpose::STANDARD
224 .decode(&blob.ciphertext)
225 .map_err(|e| AvError::InvalidPolicy(format!("Invalid ciphertext: {}", e)))?;
226
227 if nonce_bytes.len() != NONCE_LEN {
228 return Err(AvError::InvalidPolicy("Invalid nonce length".to_string()));
229 }
230
231 let nonce = Nonce::from_slice(&nonce_bytes);
232 cipher
233 .decrypt(nonce, ciphertext.as_ref())
234 .map_err(|e| AvError::InvalidPolicy(format!("Decryption failed: {}", e)))
235}
236
237pub fn encrypt_to_file<T: Serialize>(path: &std::path::Path, value: &T) -> Result<()> {
246 let json = serde_json::to_vec(value)?;
247 let blob = encrypt(&json)?;
248 let blob_json = serde_json::to_string(&blob)?;
249
250 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
253 let file_name = path
254 .file_name()
255 .unwrap_or_else(|| std::ffi::OsStr::new("tmp"));
256 let tmp_path = parent.join(format!(".{}.tmp", file_name.to_string_lossy()));
257
258 #[cfg(unix)]
261 {
262 use std::os::unix::fs::OpenOptionsExt;
263 let mut file = std::fs::OpenOptions::new()
264 .write(true)
265 .create(true)
266 .truncate(true)
267 .mode(0o600)
268 .open(&tmp_path)?;
269 std::io::Write::write_all(&mut file, blob_json.as_bytes())?;
270 use std::io::Write;
272 file.flush()?;
273 }
274 #[cfg(not(unix))]
275 {
276 std::fs::write(&tmp_path, &blob_json)?;
277 }
278
279 std::fs::rename(&tmp_path, path).map_err(|e| {
280 let _ = std::fs::remove_file(&tmp_path);
282 AvError::InvalidPolicy(format!(
283 "Failed to atomically replace {}: {}",
284 path.display(),
285 e
286 ))
287 })?;
288
289 Ok(())
290}
291
292pub fn decrypt_from_file<T: for<'de> Deserialize<'de>>(
299 path: &std::path::Path,
300) -> Result<Option<T>> {
301 if !path.exists() {
302 return Ok(None);
303 }
304
305 const MAX_ENCRYPTED_BLOB_BYTES: u64 = 1024 * 1024;
306 let mut f = std::fs::File::open(path)?;
307 let mut blob_json = String::new();
308 use std::io::Read;
309 f.by_ref()
310 .take(MAX_ENCRYPTED_BLOB_BYTES + 1)
311 .read_to_string(&mut blob_json)?;
312 if blob_json.len() as u64 > MAX_ENCRYPTED_BLOB_BYTES {
313 return Err(AvError::InvalidPolicy(format!(
314 "Encrypted blob at {} exceeds {} byte limit",
315 path.display(),
316 MAX_ENCRYPTED_BLOB_BYTES
317 )));
318 }
319
320 if let Ok(blob) = serde_json::from_str::<EncryptedBlob>(&blob_json) {
322 if !blob.nonce.is_empty() && !blob.ciphertext.is_empty() {
323 let plaintext = decrypt(&blob)?;
324 let value: T = serde_json::from_slice(&plaintext)?;
325 return Ok(Some(value));
326 }
327 }
328
329 match serde_json::from_str::<T>(&blob_json) {
331 Ok(value) => Ok(Some(value)),
332 Err(e) => Err(AvError::InvalidPolicy(format!(
333 "Failed to read cached data: {}",
334 e
335 ))),
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_encrypt_decrypt_roundtrip() {
345 let plaintext = b"secret credential data here";
346 let blob = encrypt(plaintext).unwrap();
347
348 assert_ne!(blob.ciphertext.as_bytes(), plaintext);
350
351 let decrypted = decrypt(&blob).unwrap();
352 assert_eq!(decrypted, plaintext);
353 }
354
355 #[test]
356 fn test_encrypt_decrypt_json_value() {
357 use serde_json::json;
358 let value = json!({
359 "access_key_id": "AKIAIOSFODNN7EXAMPLE",
360 "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
361 "session_token": "FwoGZXIvYXdzEBY..."
362 });
363
364 let json_bytes = serde_json::to_vec(&value).unwrap();
365 let blob = encrypt(&json_bytes).unwrap();
366 let decrypted = decrypt(&blob).unwrap();
367 let parsed: serde_json::Value = serde_json::from_slice(&decrypted).unwrap();
368
369 assert_eq!(parsed["access_key_id"], "AKIAIOSFODNN7EXAMPLE");
370 }
371
372 #[test]
373 fn test_different_encryptions_differ() {
374 let plaintext = b"same data";
375 let blob1 = encrypt(plaintext).unwrap();
376 let blob2 = encrypt(plaintext).unwrap();
377 assert_ne!(blob1.ciphertext, blob2.ciphertext);
379 assert_ne!(blob1.nonce, blob2.nonce);
380 }
381
382 #[test]
383 fn test_tampered_ciphertext_fails() {
384 let blob = encrypt(b"secret").unwrap();
385 let mut tampered = blob;
386 let mut chars: Vec<char> = tampered.ciphertext.chars().collect();
388 if let Some(c) = chars.get_mut(5) {
389 *c = if *c == 'A' { 'B' } else { 'A' };
390 }
391 tampered.ciphertext = chars.into_iter().collect();
392 assert!(decrypt(&tampered).is_err());
393 }
394
395 #[test]
396 fn test_encrypted_blob_serialization() {
397 let blob = encrypt(b"test data").unwrap();
398 let json = serde_json::to_string(&blob).unwrap();
399 let parsed: EncryptedBlob = serde_json::from_str(&json).unwrap();
400 assert_eq!(parsed.nonce, blob.nonce);
401 assert_eq!(parsed.ciphertext, blob.ciphertext);
402
403 let decrypted = decrypt(&parsed).unwrap();
404 assert_eq!(decrypted, b"test data");
405 }
406
407 #[test]
408 fn test_fallback_key_deterministic() {
409 let key1 = derive_fallback_key();
410 let key2 = derive_fallback_key();
411 assert_eq!(key1, key2);
412 }
413}