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