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