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/// Parses a DID string loaded from the encrypted store into a validated `IdentityDID`.
296///
297/// A corrupted or hand-edited keystore entry is rejected as a security error rather
298/// than wrapped unchecked — a stored DID is untrusted input on the read path.
299fn parse_stored_did(did: &str, alias: &KeyAlias) -> Result<IdentityDID, AgentError> {
300    IdentityDID::parse(did).map_err(|e| {
301        AgentError::SecurityError(format!(
302            "key '{}': malformed identity DID in keystore: {e}",
303            alias.as_str()
304        ))
305    })
306}
307
308#[allow(clippy::disallowed_methods)] // INVARIANT: file-backed keychain adapter
309#[allow(clippy::disallowed_types)]
310impl KeyStorage for EncryptedFileStorage {
311    fn store_key(
312        &self,
313        alias: &KeyAlias,
314        identity_did: &IdentityDID,
315        role: KeyRole,
316        encrypted_key_data: &[u8],
317    ) -> Result<(), AgentError> {
318        let mut data = self.read_data()?;
319        data.keys.insert(
320            alias.as_str().to_string(),
321            KeyEntry::WithRole(
322                identity_did.as_str().to_string(),
323                role.to_string(),
324                BASE64.encode(encrypted_key_data),
325            ),
326        );
327        self.write_data(&data)
328    }
329
330    fn load_key(&self, alias: &KeyAlias) -> Result<(IdentityDID, KeyRole, Vec<u8>), AgentError> {
331        let data = self.read_data()?;
332        let entry = data
333            .keys
334            .get(alias.as_str())
335            .ok_or(AgentError::KeyNotFound)?;
336        match entry {
337            KeyEntry::WithRole(did, role_str, b64) => {
338                let role = KeyRole::from_persisted(role_str).map_err(|e| {
339                    AgentError::SecurityError(format!("key '{}': {e}", alias.as_str()))
340                })?;
341                let key_bytes = BASE64.decode(b64).map_err(|e| {
342                    AgentError::StorageError(format!("Invalid key encoding: {}", e))
343                })?;
344                Ok((parse_stored_did(did, alias)?, role, key_bytes))
345            }
346            KeyEntry::Legacy(did, b64) => {
347                let key_bytes = BASE64.decode(b64).map_err(|e| {
348                    AgentError::StorageError(format!("Invalid key encoding: {}", e))
349                })?;
350                Ok((parse_stored_did(did, alias)?, KeyRole::Primary, key_bytes))
351            }
352        }
353    }
354
355    fn delete_key(&self, alias: &KeyAlias) -> Result<(), AgentError> {
356        let mut data = self.read_data()?;
357        data.keys.remove(alias.as_str());
358        self.write_data(&data)
359    }
360
361    fn list_aliases(&self) -> Result<Vec<KeyAlias>, AgentError> {
362        let data = self.read_data()?;
363        Ok(data
364            .keys
365            .keys()
366            .map(|k| KeyAlias::new_unchecked(k.clone()))
367            .collect())
368    }
369
370    fn list_aliases_for_identity(
371        &self,
372        identity_did: &IdentityDID,
373    ) -> Result<Vec<KeyAlias>, AgentError> {
374        let data = self.read_data()?;
375        let aliases = data
376            .keys
377            .iter()
378            .filter_map(|(alias, entry)| {
379                let did_str = match entry {
380                    KeyEntry::WithRole(did, _, _) | KeyEntry::Legacy(did, _) => did,
381                };
382                if did_str == identity_did.as_str() {
383                    Some(KeyAlias::new_unchecked(alias.clone()))
384                } else {
385                    None
386                }
387            })
388            .collect();
389        Ok(aliases)
390    }
391
392    fn get_identity_for_alias(&self, alias: &KeyAlias) -> Result<IdentityDID, AgentError> {
393        let data = self.read_data()?;
394        let entry = data
395            .keys
396            .get(alias.as_str())
397            .ok_or(AgentError::KeyNotFound)?;
398        let did_str = match entry {
399            KeyEntry::WithRole(did, _, _) | KeyEntry::Legacy(did, _) => did,
400        };
401        parse_stored_did(did_str, alias)
402    }
403
404    fn backend_name(&self) -> &'static str {
405        "encrypted-file"
406    }
407}
408
409#[cfg(test)]
410#[allow(clippy::disallowed_methods)]
411#[allow(clippy::disallowed_types)]
412mod tests {
413    use super::*;
414    use tempfile::TempDir;
415
416    fn create_test_storage() -> (EncryptedFileStorage, TempDir) {
417        let temp_dir = TempDir::new().unwrap();
418        let storage = EncryptedFileStorage::new(temp_dir.path()).unwrap();
419        storage.set_password(Zeroizing::new("test_password".to_string()));
420        (storage, temp_dir)
421    }
422
423    #[test]
424    fn test_encrypt_decrypt_roundtrip() {
425        let password = "test_password";
426        let mut salt = [0u8; SALT_LEN];
427        rand::rngs::OsRng.fill_bytes(&mut salt);
428        let data = b"test data for encryption";
429
430        let key = EncryptedFileStorage::derive_key(password, &salt).unwrap();
431        let (ciphertext, nonce) = EncryptedFileStorage::encrypt(&key, data).unwrap();
432        let decrypted = EncryptedFileStorage::decrypt(&key, &nonce, &ciphertext).unwrap();
433
434        assert_eq!(data.as_slice(), decrypted.as_slice());
435    }
436
437    #[test]
438    fn test_wrong_password_fails() {
439        let mut salt = [0u8; SALT_LEN];
440        rand::rngs::OsRng.fill_bytes(&mut salt);
441        let data = b"test data";
442
443        let key1 = EncryptedFileStorage::derive_key("password1", &salt).unwrap();
444        let (ciphertext, nonce) = EncryptedFileStorage::encrypt(&key1, data).unwrap();
445
446        let key2 = EncryptedFileStorage::derive_key("password2", &salt).unwrap();
447        let result = EncryptedFileStorage::decrypt(&key2, &nonce, &ciphertext);
448
449        assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
450    }
451
452    #[test]
453    fn test_store_and_load_key() {
454        let (storage, _temp) = create_test_storage();
455        let alias = KeyAlias::new("test-alias").unwrap();
456        let identity_did = IdentityDID::parse("did:keri:test123").unwrap();
457        let encrypted_data = b"encrypted_key_bytes";
458
459        storage
460            .store_key(&alias, &identity_did, KeyRole::Primary, encrypted_data)
461            .unwrap();
462
463        let (loaded_did, loaded_role, loaded_data) = storage.load_key(&alias).unwrap();
464        assert_eq!(loaded_did, identity_did);
465        assert_eq!(loaded_role, KeyRole::Primary);
466        assert_eq!(loaded_data, encrypted_data);
467    }
468
469    #[test]
470    fn keychain_file_has_no_plaintext_secret() {
471        use crate::crypto::signer::encrypt_keypair;
472        use base64::{Engine, engine::general_purpose::STANDARD as B64};
473        use ring::signature::Ed25519KeyPair;
474
475        fn contains(haystack: &[u8], needle: &[u8]) -> bool {
476            !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
477        }
478
479        let (storage, temp) = create_test_storage();
480        let alias = KeyAlias::new("secret").unwrap();
481        let identity_did = IdentityDID::parse("did:keri:test123").unwrap();
482
483        // A real keypair whose raw seed bytes we learn, then persist (encrypted).
484        let rng = ring::rand::SystemRandom::new();
485        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
486        let pkcs8_bytes = pkcs8.as_ref().to_vec();
487        let seed = pkcs8_bytes[16..48].to_vec(); // ring Ed25519 v2 seed offset
488
489        let encrypted = encrypt_keypair(&pkcs8_bytes, "Test-passphrase1!").unwrap();
490        storage
491            .store_key(&alias, &identity_did, KeyRole::Primary, &encrypted)
492            .unwrap();
493
494        let raw = std::fs::read(temp.path().join("keys.enc")).unwrap();
495        assert!(!contains(&raw, &seed), "raw seed found in keystore file");
496        assert!(
497            !contains(&raw, &pkcs8_bytes),
498            "raw private key found in keystore file"
499        );
500
501        // Genuinely encrypted, not merely encoded: the seed must not survive
502        // base64-decoding the ciphertext field.
503        let parsed: serde_json::Value = serde_json::from_slice(&raw).unwrap();
504        let ciphertext = B64.decode(parsed["ciphertext"].as_str().unwrap()).unwrap();
505        assert!(
506            !contains(&ciphertext, &seed),
507            "raw seed recoverable from the base64-decoded ciphertext (encoded, not encrypted)"
508        );
509        assert!(
510            !contains(&ciphertext, &pkcs8_bytes),
511            "raw private key recoverable from the base64-decoded ciphertext"
512        );
513    }
514
515    #[test]
516    fn load_rejects_tampered_identity_did() {
517        let (storage, _temp) = create_test_storage();
518        let alias = KeyAlias::new("tampered").unwrap();
519
520        // Simulate a corrupted or hand-edited keystore: write a DID string that never
521        // passed parse() straight into the stored entry, bypassing the validated type.
522        let mut data = KeyData::default();
523        data.keys.insert(
524            alias.as_str().to_string(),
525            KeyEntry::WithRole(
526                "not-a-did".to_string(),
527                KeyRole::Primary.to_string(),
528                BASE64.encode(b"x"),
529            ),
530        );
531        storage.write_data(&data).unwrap();
532
533        assert!(matches!(
534            storage.load_key(&alias),
535            Err(AgentError::SecurityError(_))
536        ));
537        assert!(matches!(
538            storage.get_identity_for_alias(&alias),
539            Err(AgentError::SecurityError(_))
540        ));
541    }
542
543    #[test]
544    fn test_list_aliases() {
545        let (storage, _temp) = create_test_storage();
546        let did = IdentityDID::parse("did:keri:test").unwrap();
547
548        storage
549            .store_key(
550                &KeyAlias::new("alias1").unwrap(),
551                &did,
552                KeyRole::Primary,
553                b"data1",
554            )
555            .unwrap();
556        storage
557            .store_key(
558                &KeyAlias::new("alias2").unwrap(),
559                &did,
560                KeyRole::Primary,
561                b"data2",
562            )
563            .unwrap();
564
565        let mut aliases = storage.list_aliases().unwrap();
566        aliases.sort();
567        assert_eq!(
568            aliases,
569            vec![
570                KeyAlias::new_unchecked("alias1"),
571                KeyAlias::new_unchecked("alias2")
572            ]
573        );
574    }
575
576    #[test]
577    fn test_list_aliases_for_identity() {
578        let (storage, _temp) = create_test_storage();
579        let did1 = IdentityDID::parse("did:keri:one").unwrap();
580        let did2 = IdentityDID::parse("did:keri:two").unwrap();
581
582        storage
583            .store_key(
584                &KeyAlias::new("a1").unwrap(),
585                &did1,
586                KeyRole::Primary,
587                b"data1",
588            )
589            .unwrap();
590        storage
591            .store_key(
592                &KeyAlias::new("a2").unwrap(),
593                &did1,
594                KeyRole::Primary,
595                b"data2",
596            )
597            .unwrap();
598        storage
599            .store_key(
600                &KeyAlias::new("b1").unwrap(),
601                &did2,
602                KeyRole::Primary,
603                b"data3",
604            )
605            .unwrap();
606
607        let mut aliases = storage.list_aliases_for_identity(&did1).unwrap();
608        aliases.sort();
609        assert_eq!(
610            aliases,
611            vec![KeyAlias::new_unchecked("a1"), KeyAlias::new_unchecked("a2")]
612        );
613    }
614
615    #[test]
616    fn test_delete_key() {
617        let (storage, _temp) = create_test_storage();
618        let did = IdentityDID::parse("did:keri:test").unwrap();
619        let alias = KeyAlias::new("alias").unwrap();
620
621        storage
622            .store_key(&alias, &did, KeyRole::Primary, b"data")
623            .unwrap();
624        assert!(storage.load_key(&alias).is_ok());
625
626        storage.delete_key(&alias).unwrap();
627        assert!(matches!(
628            storage.load_key(&alias),
629            Err(AgentError::KeyNotFound)
630        ));
631    }
632
633    #[test]
634    fn test_get_identity_for_alias() {
635        let (storage, _temp) = create_test_storage();
636        let did = IdentityDID::parse("did:keri:test123").unwrap();
637        let alias = KeyAlias::new("alias").unwrap();
638
639        storage
640            .store_key(&alias, &did, KeyRole::Primary, b"data")
641            .unwrap();
642
643        let loaded_did = storage.get_identity_for_alias(&alias).unwrap();
644        assert_eq!(loaded_did, did);
645    }
646
647    #[test]
648    fn test_backend_name() {
649        let (storage, _temp) = create_test_storage();
650        assert_eq!(storage.backend_name(), "encrypted-file");
651    }
652
653    #[test]
654    fn test_file_format_version() {
655        let (storage, _temp) = create_test_storage();
656        let did = IdentityDID::parse("did:keri:test").unwrap();
657
658        storage
659            .store_key(
660                &KeyAlias::new("alias").unwrap(),
661                &did,
662                KeyRole::Primary,
663                b"data",
664            )
665            .unwrap();
666
667        // Read the raw file and verify format
668        let contents = fs::read_to_string(&storage.path).unwrap();
669        let encrypted: EncryptedFileFormat = serde_json::from_str(&contents).unwrap();
670
671        assert_eq!(encrypted.version, FILE_FORMAT_VERSION);
672        assert!(!encrypted.salt.is_empty());
673        assert!(!encrypted.nonce.is_empty());
674        assert!(!encrypted.ciphertext.is_empty());
675    }
676
677    #[test]
678    fn test_missing_password_error() {
679        let temp_dir = TempDir::new().unwrap();
680        let storage = EncryptedFileStorage::new(temp_dir.path()).unwrap();
681        let did = IdentityDID::parse("did:keri:test").unwrap();
682        let result = storage.store_key(
683            &KeyAlias::new("alias").unwrap(),
684            &did,
685            KeyRole::Primary,
686            b"data",
687        );
688        assert!(matches!(result, Err(AgentError::MissingPassphrase)));
689    }
690
691    #[test]
692    fn test_key_not_found() {
693        let (storage, _temp) = create_test_storage();
694
695        let result = storage.load_key(&KeyAlias::new("nonexistent").unwrap());
696        assert!(matches!(result, Err(AgentError::KeyNotFound)));
697    }
698
699    #[test]
700    fn test_legacy_key_data_migration() {
701        // Simulate old format: (did, b64_key) without role
702        let old_json = r#"{"keys":{"my-key":["did:keri:Eabc","dGVzdA=="]}}"#;
703        let data: KeyData = serde_json::from_str(old_json).unwrap();
704        let entry = data.keys.get("my-key").unwrap();
705        match entry {
706            KeyEntry::Legacy(did, _b64) => assert_eq!(did, "did:keri:Eabc"),
707            KeyEntry::WithRole(..) => panic!("should deserialize as Legacy"),
708        }
709    }
710
711    #[test]
712    fn test_new_key_data_format() {
713        let new_json = r#"{"keys":{"my-key":["did:keri:Eabc","primary","dGVzdA=="]}}"#;
714        let data: KeyData = serde_json::from_str(new_json).unwrap();
715        let entry = data.keys.get("my-key").unwrap();
716        match entry {
717            KeyEntry::WithRole(did, role, _b64) => {
718                assert_eq!(did, "did:keri:Eabc");
719                assert_eq!(role, "primary");
720            }
721            KeyEntry::Legacy(..) => panic!("should deserialize as WithRole"),
722        }
723    }
724}