Skip to main content

auths_core/storage/
encrypted_file.rs

1//! Encrypted file-based key storage for headless environments.
2//!
3//! Uses Argon2id for key derivation and XChaCha20-Poly1305 for encryption.
4//! Stores keys in `~/.auths/keys.enc` with Unix permissions 0600.
5
6use crate::error::AgentError;
7use crate::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
8use argon2::{Argon2, Version};
9use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
10use chacha20poly1305::{
11    XChaCha20Poly1305, XNonce,
12    aead::{Aead, KeyInit},
13};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::fs::{self, File, OpenOptions};
17use std::io::{Read, Write};
18use std::path::PathBuf;
19use std::sync::Mutex;
20use zeroize::Zeroizing;
21
22/// XChaCha20-Poly1305 uses a 192-bit (24-byte) nonce
23const XCHACHA_NONCE_LEN: usize = 24;
24/// 256-bit key for XChaCha20-Poly1305
25const KEY_LEN: usize = 32;
26/// Argon2id salt length
27const SALT_LEN: usize = 16;
28
29/// File format version for future compatibility
30const FILE_FORMAT_VERSION: u32 = 1;
31
32/// Encrypted file format stored on disk
33#[derive(Debug, Serialize, Deserialize)]
34struct EncryptedFileFormat {
35    version: u32,
36    salt: String,       // base64 encoded
37    nonce: String,      // base64 encoded
38    ciphertext: String, // base64 encoded
39}
40
41/// Internal key data structure (plaintext, stored in ciphertext)
42#[derive(Debug, Serialize, Deserialize, Default)]
43struct KeyData {
44    /// alias -> (identity_did, encrypted_key_bytes as base64)
45    keys: HashMap<String, (String, String)>,
46}
47
48/// Encrypted file storage for headless Linux environments.
49///
50/// Stores keys in an encrypted JSON file at `~/.auths/keys.enc`.
51/// Uses Argon2id for password-based key derivation and XChaCha20-Poly1305 for encryption.
52pub struct EncryptedFileStorage {
53    path: PathBuf,
54    /// Cached password for the session (zeroized on drop)
55    password: Mutex<Option<Zeroizing<String>>>,
56}
57
58impl EncryptedFileStorage {
59    /// Create a new EncryptedFileStorage with default path (`<home>/keys.enc`).
60    ///
61    /// Args:
62    /// * `home` - The Auths home directory (e.g., from `auths_home_with_config`).
63    ///
64    /// Usage:
65    /// ```ignore
66    /// let storage = EncryptedFileStorage::new(home_path)?;
67    /// ```
68    pub fn new(home: &std::path::Path) -> Result<Self, AgentError> {
69        Self::with_path(home.join("keys.enc"))
70    }
71
72    /// Create a new EncryptedFileStorage with a custom path
73    pub fn with_path(path: PathBuf) -> Result<Self, AgentError> {
74        // Ensure parent directory exists
75        if let Some(parent) = path.parent() {
76            fs::create_dir_all(parent).map_err(|e| {
77                AgentError::StorageError(format!(
78                    "Failed to create directory {}: {}",
79                    parent.display(),
80                    e
81                ))
82            })?;
83        }
84        Ok(Self {
85            path,
86            password: Mutex::new(None),
87        })
88    }
89
90    /// Set the password for this session.
91    ///
92    /// Takes `Zeroizing<String>` to enforce that callers treat the passphrase
93    /// as sensitive material from the point of construction.
94    #[allow(clippy::unwrap_used)] // mutex poisoning is fatal by design
95    pub fn set_password(&self, password: Zeroizing<String>) {
96        let mut guard = self.password.lock().unwrap();
97        *guard = Some(password);
98    }
99
100    /// Get the cached password set via `set_password`.
101    #[allow(clippy::unwrap_used)] // mutex poisoning is fatal by design
102    fn get_password(&self) -> Result<Zeroizing<String>, AgentError> {
103        self.password
104            .lock()
105            .unwrap()
106            .clone()
107            .ok_or(AgentError::MissingPassphrase)
108    }
109
110    /// Derive a 256-bit key from password using Argon2id
111    fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, AgentError> {
112        let params = crate::crypto::encryption::get_kdf_params()?;
113
114        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);
115
116        let mut key = Zeroizing::new([0u8; KEY_LEN]);
117        argon2
118            .hash_password_into(password.as_bytes(), salt, key.as_mut())
119            .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
120
121        Ok(key)
122    }
123
124    /// Encrypt data with XChaCha20-Poly1305
125    fn encrypt(
126        key: &[u8; KEY_LEN],
127        data: &[u8],
128    ) -> Result<(Vec<u8>, [u8; XCHACHA_NONCE_LEN]), AgentError> {
129        let nonce: [u8; XCHACHA_NONCE_LEN] = rand::random();
130        let cipher = XChaCha20Poly1305::new_from_slice(key)
131            .map_err(|e| AgentError::CryptoError(format!("Invalid key: {}", e)))?;
132
133        let ciphertext = cipher
134            .encrypt(XNonce::from_slice(&nonce), data)
135            .map_err(|e| AgentError::CryptoError(format!("Encryption failed: {}", e)))?;
136
137        Ok((ciphertext, nonce))
138    }
139
140    /// Decrypt data with XChaCha20-Poly1305
141    fn decrypt(
142        key: &[u8; KEY_LEN],
143        nonce: &[u8],
144        ciphertext: &[u8],
145    ) -> Result<Vec<u8>, AgentError> {
146        let cipher = XChaCha20Poly1305::new_from_slice(key)
147            .map_err(|e| AgentError::CryptoError(format!("Invalid key: {}", e)))?;
148
149        cipher
150            .decrypt(XNonce::from_slice(nonce), ciphertext)
151            .map_err(|_| AgentError::IncorrectPassphrase)
152    }
153
154    /// Read and decrypt the key data from disk
155    fn read_data(&self) -> Result<KeyData, AgentError> {
156        if !self.path.exists() {
157            return Ok(KeyData::default());
158        }
159
160        let password = self.get_password()?;
161
162        let mut file = File::open(&self.path).map_err(|e| {
163            AgentError::StorageError(format!("Failed to open {}: {}", self.path.display(), e))
164        })?;
165
166        let mut contents = String::new();
167        file.read_to_string(&mut contents).map_err(|e| {
168            AgentError::StorageError(format!("Failed to read {}: {}", self.path.display(), e))
169        })?;
170
171        let encrypted: EncryptedFileFormat = serde_json::from_str(&contents)
172            .map_err(|e| AgentError::StorageError(format!("Invalid file format: {}", e)))?;
173
174        if encrypted.version != FILE_FORMAT_VERSION {
175            return Err(AgentError::StorageError(format!(
176                "Unsupported file format version: {} (expected {})",
177                encrypted.version, FILE_FORMAT_VERSION
178            )));
179        }
180
181        let salt = BASE64
182            .decode(&encrypted.salt)
183            .map_err(|e| AgentError::StorageError(format!("Invalid salt encoding: {}", e)))?;
184        let nonce = BASE64
185            .decode(&encrypted.nonce)
186            .map_err(|e| AgentError::StorageError(format!("Invalid nonce encoding: {}", e)))?;
187        let ciphertext = BASE64
188            .decode(&encrypted.ciphertext)
189            .map_err(|e| AgentError::StorageError(format!("Invalid ciphertext encoding: {}", e)))?;
190
191        let key = Self::derive_key(&password, &salt)?;
192        let plaintext = Self::decrypt(&key, &nonce, &ciphertext)?;
193
194        let data: KeyData = serde_json::from_slice(&plaintext)
195            .map_err(|e| AgentError::StorageError(format!("Failed to parse key data: {}", e)))?;
196
197        Ok(data)
198    }
199
200    /// Encrypt and write key data to disk
201    fn write_data(&self, data: &KeyData) -> Result<(), AgentError> {
202        let password = self.get_password()?;
203
204        let plaintext = serde_json::to_vec(data).map_err(|e| {
205            AgentError::StorageError(format!("Failed to serialize key data: {}", e))
206        })?;
207
208        let salt: [u8; SALT_LEN] = rand::random();
209        let key = Self::derive_key(&password, &salt)?;
210        let (ciphertext, nonce) = Self::encrypt(&key, &plaintext)?;
211
212        let encrypted = EncryptedFileFormat {
213            version: FILE_FORMAT_VERSION,
214            salt: BASE64.encode(salt),
215            nonce: BASE64.encode(nonce),
216            ciphertext: BASE64.encode(&ciphertext),
217        };
218
219        let contents = serde_json::to_string_pretty(&encrypted).map_err(|e| {
220            AgentError::StorageError(format!("Failed to serialize encrypted data: {}", e))
221        })?;
222
223        // Write to a temp file first, then rename for atomicity
224        let temp_path = self.path.with_extension("tmp");
225
226        {
227            let mut file = OpenOptions::new()
228                .write(true)
229                .create(true)
230                .truncate(true)
231                .open(&temp_path)
232                .map_err(|e| {
233                    AgentError::StorageError(format!(
234                        "Failed to create temp file {}: {}",
235                        temp_path.display(),
236                        e
237                    ))
238                })?;
239
240            // Set file permissions to 0600 on Unix
241            #[cfg(unix)]
242            {
243                use std::os::unix::fs::PermissionsExt;
244                let perms = std::fs::Permissions::from_mode(0o600);
245                file.set_permissions(perms).map_err(|e| {
246                    AgentError::StorageError(format!("Failed to set file permissions: {}", e))
247                })?;
248            }
249
250            file.write_all(contents.as_bytes()).map_err(|e| {
251                AgentError::StorageError(format!(
252                    "Failed to write to {}: {}",
253                    temp_path.display(),
254                    e
255                ))
256            })?;
257
258            file.sync_all()
259                .map_err(|e| AgentError::StorageError(format!("Failed to sync file: {}", e)))?;
260        }
261
262        // Atomic rename
263        fs::rename(&temp_path, &self.path).map_err(|e| {
264            AgentError::StorageError(format!(
265                "Failed to rename {} to {}: {}",
266                temp_path.display(),
267                self.path.display(),
268                e
269            ))
270        })?;
271
272        Ok(())
273    }
274}
275
276impl KeyStorage for EncryptedFileStorage {
277    fn store_key(
278        &self,
279        alias: &KeyAlias,
280        identity_did: &IdentityDID,
281        encrypted_key_data: &[u8],
282    ) -> Result<(), AgentError> {
283        let mut data = self.read_data()?;
284        data.keys.insert(
285            alias.as_str().to_string(),
286            (
287                identity_did.as_str().to_string(),
288                BASE64.encode(encrypted_key_data),
289            ),
290        );
291        self.write_data(&data)
292    }
293
294    fn load_key(&self, alias: &KeyAlias) -> Result<(IdentityDID, Vec<u8>), AgentError> {
295        let data = self.read_data()?;
296        let (did, key_b64) = data
297            .keys
298            .get(alias.as_str())
299            .ok_or(AgentError::KeyNotFound)?;
300        let key_bytes = BASE64
301            .decode(key_b64)
302            .map_err(|e| AgentError::StorageError(format!("Invalid key encoding: {}", e)))?;
303        Ok((IdentityDID::new_unchecked(did.clone()), key_bytes))
304    }
305
306    fn delete_key(&self, alias: &KeyAlias) -> Result<(), AgentError> {
307        let mut data = self.read_data()?;
308        data.keys.remove(alias.as_str());
309        self.write_data(&data)
310    }
311
312    fn list_aliases(&self) -> Result<Vec<KeyAlias>, AgentError> {
313        let data = self.read_data()?;
314        Ok(data
315            .keys
316            .keys()
317            .map(|k| KeyAlias::new_unchecked(k.clone()))
318            .collect())
319    }
320
321    fn list_aliases_for_identity(
322        &self,
323        identity_did: &IdentityDID,
324    ) -> Result<Vec<KeyAlias>, AgentError> {
325        let data = self.read_data()?;
326        let aliases = data
327            .keys
328            .iter()
329            .filter_map(|(alias, (did, _))| {
330                if did == identity_did.as_str() {
331                    Some(KeyAlias::new_unchecked(alias.clone()))
332                } else {
333                    None
334                }
335            })
336            .collect();
337        Ok(aliases)
338    }
339
340    fn get_identity_for_alias(&self, alias: &KeyAlias) -> Result<IdentityDID, AgentError> {
341        let data = self.read_data()?;
342        data.keys
343            .get(alias.as_str())
344            .map(|(did, _)| IdentityDID::new_unchecked(did.clone()))
345            .ok_or(AgentError::KeyNotFound)
346    }
347
348    fn backend_name(&self) -> &'static str {
349        "encrypted-file"
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use tempfile::TempDir;
357
358    fn create_test_storage() -> (EncryptedFileStorage, TempDir) {
359        let temp_dir = TempDir::new().unwrap();
360        let storage = EncryptedFileStorage::new(temp_dir.path()).unwrap();
361        storage.set_password(Zeroizing::new("test_password".to_string()));
362        (storage, temp_dir)
363    }
364
365    #[test]
366    fn test_encrypt_decrypt_roundtrip() {
367        let password = "test_password";
368        let salt: [u8; SALT_LEN] = rand::random();
369        let data = b"test data for encryption";
370
371        let key = EncryptedFileStorage::derive_key(password, &salt).unwrap();
372        let (ciphertext, nonce) = EncryptedFileStorage::encrypt(&key, data).unwrap();
373        let decrypted = EncryptedFileStorage::decrypt(&key, &nonce, &ciphertext).unwrap();
374
375        assert_eq!(data.as_slice(), decrypted.as_slice());
376    }
377
378    #[test]
379    fn test_wrong_password_fails() {
380        let salt: [u8; SALT_LEN] = rand::random();
381        let data = b"test data";
382
383        let key1 = EncryptedFileStorage::derive_key("password1", &salt).unwrap();
384        let (ciphertext, nonce) = EncryptedFileStorage::encrypt(&key1, data).unwrap();
385
386        let key2 = EncryptedFileStorage::derive_key("password2", &salt).unwrap();
387        let result = EncryptedFileStorage::decrypt(&key2, &nonce, &ciphertext);
388
389        assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
390    }
391
392    #[test]
393    fn test_store_and_load_key() {
394        let (storage, _temp) = create_test_storage();
395        let alias = KeyAlias::new("test-alias").unwrap();
396        let identity_did = IdentityDID::new("did:keri:test123");
397        let encrypted_data = b"encrypted_key_bytes";
398
399        storage
400            .store_key(&alias, &identity_did, encrypted_data)
401            .unwrap();
402
403        let (loaded_did, loaded_data) = storage.load_key(&alias).unwrap();
404        assert_eq!(loaded_did, identity_did);
405        assert_eq!(loaded_data, encrypted_data);
406    }
407
408    #[test]
409    fn test_list_aliases() {
410        let (storage, _temp) = create_test_storage();
411        let did = IdentityDID::new("did:keri:test");
412
413        storage
414            .store_key(&KeyAlias::new("alias1").unwrap(), &did, b"data1")
415            .unwrap();
416        storage
417            .store_key(&KeyAlias::new("alias2").unwrap(), &did, b"data2")
418            .unwrap();
419
420        let mut aliases = storage.list_aliases().unwrap();
421        aliases.sort();
422        assert_eq!(
423            aliases,
424            vec![
425                KeyAlias::new_unchecked("alias1"),
426                KeyAlias::new_unchecked("alias2")
427            ]
428        );
429    }
430
431    #[test]
432    fn test_list_aliases_for_identity() {
433        let (storage, _temp) = create_test_storage();
434        let did1 = IdentityDID::new("did:keri:one");
435        let did2 = IdentityDID::new("did:keri:two");
436
437        storage
438            .store_key(&KeyAlias::new("a1").unwrap(), &did1, b"data1")
439            .unwrap();
440        storage
441            .store_key(&KeyAlias::new("a2").unwrap(), &did1, b"data2")
442            .unwrap();
443        storage
444            .store_key(&KeyAlias::new("b1").unwrap(), &did2, b"data3")
445            .unwrap();
446
447        let mut aliases = storage.list_aliases_for_identity(&did1).unwrap();
448        aliases.sort();
449        assert_eq!(
450            aliases,
451            vec![KeyAlias::new_unchecked("a1"), KeyAlias::new_unchecked("a2")]
452        );
453    }
454
455    #[test]
456    fn test_delete_key() {
457        let (storage, _temp) = create_test_storage();
458        let did = IdentityDID::new("did:keri:test");
459        let alias = KeyAlias::new("alias").unwrap();
460
461        storage.store_key(&alias, &did, b"data").unwrap();
462        assert!(storage.load_key(&alias).is_ok());
463
464        storage.delete_key(&alias).unwrap();
465        assert!(matches!(
466            storage.load_key(&alias),
467            Err(AgentError::KeyNotFound)
468        ));
469    }
470
471    #[test]
472    fn test_get_identity_for_alias() {
473        let (storage, _temp) = create_test_storage();
474        let did = IdentityDID::new("did:keri:test123");
475        let alias = KeyAlias::new("alias").unwrap();
476
477        storage.store_key(&alias, &did, b"data").unwrap();
478
479        let loaded_did = storage.get_identity_for_alias(&alias).unwrap();
480        assert_eq!(loaded_did, did);
481    }
482
483    #[test]
484    fn test_backend_name() {
485        let (storage, _temp) = create_test_storage();
486        assert_eq!(storage.backend_name(), "encrypted-file");
487    }
488
489    #[test]
490    fn test_file_format_version() {
491        let (storage, _temp) = create_test_storage();
492        let did = IdentityDID::new("did:keri:test");
493
494        storage
495            .store_key(&KeyAlias::new("alias").unwrap(), &did, b"data")
496            .unwrap();
497
498        // Read the raw file and verify format
499        let contents = fs::read_to_string(&storage.path).unwrap();
500        let encrypted: EncryptedFileFormat = serde_json::from_str(&contents).unwrap();
501
502        assert_eq!(encrypted.version, FILE_FORMAT_VERSION);
503        assert!(!encrypted.salt.is_empty());
504        assert!(!encrypted.nonce.is_empty());
505        assert!(!encrypted.ciphertext.is_empty());
506    }
507
508    #[test]
509    fn test_missing_password_error() {
510        let temp_dir = TempDir::new().unwrap();
511        let storage = EncryptedFileStorage::new(temp_dir.path()).unwrap();
512        let did = IdentityDID::new_unchecked("did:test".to_string());
513        let result = storage.store_key(&KeyAlias::new("alias").unwrap(), &did, b"data");
514        assert!(matches!(result, Err(AgentError::MissingPassphrase)));
515    }
516
517    #[test]
518    fn test_key_not_found() {
519        let (storage, _temp) = create_test_storage();
520
521        let result = storage.load_key(&KeyAlias::new("nonexistent").unwrap());
522        assert!(matches!(result, Err(AgentError::KeyNotFound)));
523    }
524}