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