Skip to main content

tryaudex_core/
keystore.rs

1use 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/// Encrypted blob stored on disk instead of plaintext JSON.
16#[derive(Debug, Serialize, Deserialize)]
17pub struct EncryptedBlob {
18    /// Base64-encoded nonce
19    pub nonce: String,
20    /// Base64-encoded ciphertext
21    pub ciphertext: String,
22}
23
24/// Get or create the encryption key from the OS keychain.
25/// Falls back to a machine-derived key if keychain is unavailable.
26/// Key is cached per-process to ensure consistency.
27pub(crate) fn get_or_create_key() -> [u8; 32] {
28    *CACHED_KEY.get_or_init(|| {
29        // Try OS keychain first
30        if let Ok(key) = load_key_from_keychain() {
31            return key;
32        }
33
34        // Try to generate and store a new key
35        if let Ok(key) = generate_and_store_key() {
36            return key;
37        }
38
39        // Fallback: derive from machine-specific data
40        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
73/// Derive a key from machine-specific data plus a per-user random salt.
74///
75/// The salt is generated once and stored in `~/.config/audex/keystore-salt`
76/// with owner-only permissions (0600). Without the salt file, the key cannot
77/// be reproduced even though the other inputs (USER, home_dir, machine-id)
78/// are publicly readable.
79///
80/// This is a fallback for environments without a keychain daemon (headless CI,
81/// containers). For production use, ensure an OS keychain is available or set
82/// AUDEX_HMAC_KEY for audit integrity.
83///
84/// # Panics
85///
86/// Panics (via `expect`) when the salt file cannot be created, because
87/// proceeding with only public inputs would allow any local user to recompute
88/// the key. Set `AUDEX_HMAC_KEY` to bypass the keystore in environments with
89/// a read-only filesystem.
90fn 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        // The salt is the sole source of entropy that prevents local users
100        // from recomputing the fallback key. Without it the key is derived
101        // entirely from public inputs (USER, home dir, machine-id) and
102        // offers no real protection. Fail hard rather than silently produce
103        // a weak key.
104        //
105        // If you are running in a container or on a read-only filesystem,
106        // set the AUDEX_HMAC_KEY environment variable to supply an
107        // explicit high-entropy key instead.
108        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    // Mix in the per-user random salt — the primary entropy source.
122    hasher.update(&salt);
123
124    // Mix in username
125    if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("LOGNAME")) {
126        hasher.update(user.as_bytes());
127    }
128
129    // Mix in home directory
130    if let Some(home) = dirs::home_dir() {
131        hasher.update(home.to_string_lossy().as_bytes());
132    }
133
134    // Mix in machine-id if available (Linux)
135    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
142/// Load or create a 32-byte random salt in `~/.config/audex/keystore-salt`.
143fn load_or_create_salt() -> Option<Vec<u8>> {
144    let dir = dirs::config_dir()?.join("audex");
145    let path = dir.join("keystore-salt");
146
147    // Try to read existing salt
148    if let Ok(data) = std::fs::read(&path) {
149        if data.len() == 32 {
150            return Some(data);
151        }
152    }
153
154    // Generate new salt
155    use rand::Rng;
156    let mut salt = [0u8; 32];
157    rand::rng().fill(&mut salt);
158
159    // Store with restrictive permissions
160    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
191/// Encrypt data using AES-256-GCM with a key from the OS keychain.
192pub 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
213/// Decrypt data using AES-256-GCM with a key from the OS keychain.
214pub 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
237/// Encrypt a serializable value and write to a file.
238///
239/// R6-M52: write to a temporary file in the same directory, then
240/// atomically rename into place. A crash or power loss during the
241/// write previously left the target file truncated (zero bytes or
242/// partial JSON), which `decrypt_from_file` would fail to parse —
243/// silently destroying the cached SSO session or credential. The
244/// rename is atomic on POSIX and near-atomic on Windows/NTFS.
245pub 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    // Build a temp path in the same directory so rename doesn't cross
251    // filesystem boundaries.
252    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    // Write with restrictive permissions (owner-only) since these files
259    // contain encrypted credentials and SSO sessions.
260    #[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        // Flush to disk before the rename so the data is durable.
271        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        // Clean up the temp file on rename failure.
281        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
292/// Read and decrypt a value from a file.
293///
294/// R6-M51: cap the read at 1 MiB. The previous unbounded
295/// `read_to_string` would follow a symlink to /dev/urandom or a
296/// multi-GB log and OOM the process. Encrypted blobs are a few KB;
297/// 1 MiB is generous.
298pub 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    // Try to decrypt as encrypted blob
321    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    // Fallback: try reading as plaintext JSON (migration from unencrypted)
330    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        // Verify it's not plaintext
349        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        // Different nonces should produce different ciphertexts
378        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        // Flip a character in the ciphertext
387        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}