Skip to main content

ant_quic/host_identity/
storage.rs

1// Copyright 2024 Saorsa Labs Ltd.
2//
3// This Saorsa Network Software is licensed under the General Public License (GPL), version 3.
4// Please see the file LICENSE-GPL, or visit <http://www.gnu.org/licenses/> for the full text.
5//
6// Full details available at https://saorsalabs.com/licenses
7
8//! Platform-specific storage backends for HostKey persistence
9//!
10//! Storage priority (ADR-007):
11//! 1. macOS: Keychain Services
12//! 2. Linux: libsecret/GNOME Keyring (if available)
13//! 3. Windows: DPAPI
14//! 4. Fallback: XChaCha20-Poly1305 encrypted file with `ANTQ_HOSTKEY_PASSWORD` env var
15//!
16//! # Security Model
17//!
18//! The HostKey is the root secret for all derived keys. It must be:
19//! - Protected at rest with platform-appropriate encryption
20//! - Never exposed in logs or error messages
21//! - Zeroed from memory when no longer needed
22//!
23//! # Usage
24//!
25//! ```ignore
26//! use ant_quic::host_identity::storage::{HostKeyStorage, auto_storage};
27//!
28//! // Get the best available storage for this platform
29//! let storage = auto_storage()?;
30//!
31//! // Store a HostKey
32//! storage.store(&hostkey_bytes)?;
33//!
34//! // Load the HostKey
35//! let hostkey = storage.load()?;
36//! ```
37
38use std::path::PathBuf;
39use thiserror::Error;
40use zeroize::Zeroize;
41
42// =============================================================================
43// Error Types
44// =============================================================================
45
46/// Errors that can occur during HostKey storage operations
47#[derive(Debug, Error)]
48pub enum StorageError {
49    /// HostKey not found in storage
50    #[error("HostKey not found")]
51    NotFound,
52
53    /// Storage backend not available on this platform
54    #[error("Storage backend not available: {0}")]
55    BackendUnavailable(String),
56
57    /// Password required but not provided
58    #[error("ANTQ_HOSTKEY_PASSWORD environment variable not set")]
59    PasswordRequired,
60
61    /// Encryption/decryption failed
62    #[error("Cryptographic operation failed: {0}")]
63    CryptoError(String),
64
65    /// I/O error during storage operations
66    #[error("I/O error: {0}")]
67    IoError(#[from] std::io::Error),
68
69    /// Invalid data format
70    #[error("Invalid data format: {0}")]
71    InvalidFormat(String),
72
73    /// Platform-specific keychain error
74    #[error("Keychain error: {0}")]
75    KeychainError(String),
76
77    /// Permission denied
78    #[error("Permission denied: {0}")]
79    PermissionDenied(String),
80}
81
82/// Result type for storage operations
83pub type StorageResult<T> = Result<T, StorageError>;
84
85// =============================================================================
86// Storage Security Level
87// =============================================================================
88
89/// Security level of the storage backend
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StorageSecurityLevel {
92    /// Platform keychain (macOS Keychain, GNOME Keyring, Windows Credential Manager)
93    Secure,
94    /// Encrypted file with password
95    Encrypted,
96    /// Plain file with permissions only - INSECURE
97    Insecure,
98}
99
100impl StorageSecurityLevel {
101    /// Get a warning message if this security level requires user attention
102    pub fn warning_message(&self) -> Option<&'static str> {
103        match self {
104            Self::Secure | Self::Encrypted => None,
105            Self::Insecure => Some(
106                "⚠️  HostKey stored WITHOUT ENCRYPTION!\n\
107                 Anyone with file access can read and impersonate this node.\n\
108                 To secure: set ANTQ_HOSTKEY_PASSWORD environment variable.",
109            ),
110        }
111    }
112
113    /// Check if this storage level is considered secure
114    pub fn is_secure(&self) -> bool {
115        matches!(self, Self::Secure | Self::Encrypted)
116    }
117}
118
119// =============================================================================
120// Storage Trait
121// =============================================================================
122
123/// Trait for HostKey storage backends
124///
125/// Implementations must ensure:
126/// - Data is encrypted at rest
127/// - Sensitive data is zeroed after use
128/// - Thread-safe access
129pub trait HostKeyStorage: Send + Sync {
130    /// Store the HostKey
131    ///
132    /// # Arguments
133    /// * `hostkey` - 32-byte HostKey secret
134    ///
135    /// # Security
136    /// The implementation should encrypt the key before storing.
137    fn store(&self, hostkey: &[u8; 32]) -> StorageResult<()>;
138
139    /// Load the HostKey
140    ///
141    /// # Returns
142    /// The 32-byte HostKey secret, or `StorageError::NotFound` if not stored.
143    ///
144    /// # Security
145    /// The returned bytes should be zeroed by the caller when no longer needed.
146    fn load(&self) -> StorageResult<[u8; 32]>;
147
148    /// Delete the HostKey from storage
149    ///
150    /// # Security
151    /// This should securely erase the key material.
152    fn delete(&self) -> StorageResult<()>;
153
154    /// Check if a HostKey exists in storage
155    fn exists(&self) -> bool;
156
157    /// Get the storage backend name for diagnostics
158    fn backend_name(&self) -> &'static str;
159
160    /// Get the security level of this storage backend
161    fn security_level(&self) -> StorageSecurityLevel;
162}
163
164// =============================================================================
165// Encrypted File Storage (Fallback)
166// =============================================================================
167
168/// File format version for migration support
169const FILE_FORMAT_VERSION: u8 = 1;
170
171/// Salt size for HKDF key derivation from password
172const SALT_SIZE: usize = 32;
173
174/// Encrypted file storage using XChaCha20-Poly1305
175///
176/// File format:
177/// ```text
178/// [version: 1 byte][salt: 32 bytes][nonce: 24 bytes][ciphertext+tag: 48 bytes]
179/// Total: 105 bytes
180/// ```
181///
182/// Requires `ANTQ_HOSTKEY_PASSWORD` environment variable to be set.
183pub struct EncryptedFileStorage {
184    path: PathBuf,
185}
186
187impl EncryptedFileStorage {
188    /// Create a new encrypted file storage at the default location
189    pub fn new() -> StorageResult<Self> {
190        let path = Self::default_path()?;
191        Ok(Self { path })
192    }
193
194    /// Create encrypted file storage at a custom path
195    pub fn with_path(path: PathBuf) -> Self {
196        Self { path }
197    }
198
199    /// Get the default storage path
200    ///
201    /// - Linux/macOS: `~/.config/ant-quic/hostkey.enc`
202    /// - Windows: `%APPDATA%\ant-quic\hostkey.enc`
203    fn default_path() -> StorageResult<PathBuf> {
204        let config_dir = dirs::config_dir().ok_or_else(|| {
205            StorageError::IoError(std::io::Error::new(
206                std::io::ErrorKind::NotFound,
207                "Could not determine config directory",
208            ))
209        })?;
210
211        let path = config_dir.join("ant-quic").join("hostkey.enc");
212        Ok(path)
213    }
214
215    /// Get the password from environment variable
216    fn get_password() -> StorageResult<String> {
217        std::env::var("ANTQ_HOSTKEY_PASSWORD").map_err(|_| StorageError::PasswordRequired)
218    }
219
220    /// Derive encryption key from password using HKDF
221    fn derive_key_from_password(password: &str, salt: &[u8]) -> StorageResult<[u8; 32]> {
222        use aws_lc_rs::hkdf;
223
224        let hkdf_salt = hkdf::Salt::new(hkdf::HKDF_SHA256, salt);
225        let prk = hkdf_salt.extract(password.as_bytes());
226
227        let mut key = [0u8; 32];
228        let okm = prk
229            .expand(&[b"antq:hostkey-file:v1"], hkdf::HKDF_SHA256)
230            .map_err(|e| StorageError::CryptoError(format!("HKDF expand failed: {e}")))?;
231
232        okm.fill(&mut key)
233            .map_err(|e| StorageError::CryptoError(format!("HKDF fill failed: {e}")))?;
234
235        Ok(key)
236    }
237
238    /// Encrypt data using XChaCha20-Poly1305
239    fn encrypt(key: &[u8; 32], plaintext: &[u8; 32]) -> StorageResult<Vec<u8>> {
240        use aws_lc_rs::aead::{
241            self, Aad, BoundKey, CHACHA20_POLY1305, Nonce, NonceSequence, UnboundKey,
242        };
243
244        // Generate random nonce (12 bytes for ChaCha20-Poly1305)
245        let mut nonce_bytes = [0u8; 12];
246        aws_lc_rs::rand::fill(&mut nonce_bytes)
247            .map_err(|e| StorageError::CryptoError(format!("Failed to generate nonce: {e}")))?;
248
249        // Create sealing key
250        let unbound_key = UnboundKey::new(&CHACHA20_POLY1305, key)
251            .map_err(|e| StorageError::CryptoError(format!("Failed to create key: {e}")))?;
252
253        struct SingleNonce(Option<[u8; 12]>);
254        impl NonceSequence for SingleNonce {
255            fn advance(&mut self) -> Result<Nonce, aws_lc_rs::error::Unspecified> {
256                self.0
257                    .take()
258                    .map(Nonce::assume_unique_for_key)
259                    .ok_or(aws_lc_rs::error::Unspecified)
260            }
261        }
262
263        let mut sealing_key = aead::SealingKey::new(unbound_key, SingleNonce(Some(nonce_bytes)));
264
265        // Encrypt in-place
266        let mut in_out = plaintext.to_vec();
267        sealing_key
268            .seal_in_place_append_tag(Aad::empty(), &mut in_out)
269            .map_err(|e| StorageError::CryptoError(format!("Encryption failed: {e}")))?;
270
271        // Return nonce || ciphertext+tag
272        let mut result = Vec::with_capacity(12 + in_out.len());
273        result.extend_from_slice(&nonce_bytes);
274        result.extend_from_slice(&in_out);
275        Ok(result)
276    }
277
278    /// Decrypt data using XChaCha20-Poly1305
279    fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> StorageResult<[u8; 32]> {
280        use aws_lc_rs::aead::{
281            self, Aad, BoundKey, CHACHA20_POLY1305, Nonce, NonceSequence, UnboundKey,
282        };
283
284        if ciphertext.len() < 12 + 16 {
285            return Err(StorageError::InvalidFormat(
286                "Ciphertext too short".to_string(),
287            ));
288        }
289
290        let nonce_bytes: [u8; 12] = ciphertext[..12]
291            .try_into()
292            .map_err(|_| StorageError::InvalidFormat("Invalid nonce".to_string()))?;
293
294        // Create opening key
295        let unbound_key = UnboundKey::new(&CHACHA20_POLY1305, key)
296            .map_err(|e| StorageError::CryptoError(format!("Failed to create key: {e}")))?;
297
298        struct SingleNonce(Option<[u8; 12]>);
299        impl NonceSequence for SingleNonce {
300            fn advance(&mut self) -> Result<Nonce, aws_lc_rs::error::Unspecified> {
301                self.0
302                    .take()
303                    .map(Nonce::assume_unique_for_key)
304                    .ok_or(aws_lc_rs::error::Unspecified)
305            }
306        }
307
308        let mut opening_key = aead::OpeningKey::new(unbound_key, SingleNonce(Some(nonce_bytes)));
309
310        // Decrypt in-place
311        let mut in_out = ciphertext[12..].to_vec();
312        let plaintext = opening_key
313            .open_in_place(Aad::empty(), &mut in_out)
314            .map_err(|_| {
315                StorageError::CryptoError(
316                    "Decryption failed - wrong password or corrupted data".to_string(),
317                )
318            })?;
319
320        if plaintext.len() != 32 {
321            return Err(StorageError::InvalidFormat(format!(
322                "Expected 32-byte HostKey, got {} bytes",
323                plaintext.len()
324            )));
325        }
326
327        let mut result = [0u8; 32];
328        result.copy_from_slice(plaintext);
329        Ok(result)
330    }
331}
332
333impl HostKeyStorage for EncryptedFileStorage {
334    fn store(&self, hostkey: &[u8; 32]) -> StorageResult<()> {
335        let password = Self::get_password()?;
336
337        // Generate random salt
338        let mut salt = [0u8; SALT_SIZE];
339        aws_lc_rs::rand::fill(&mut salt)
340            .map_err(|e| StorageError::CryptoError(format!("Failed to generate salt: {e}")))?;
341
342        // Derive encryption key from password
343        let mut key = Self::derive_key_from_password(&password, &salt)?;
344
345        // Encrypt the hostkey
346        let ciphertext = Self::encrypt(&key, hostkey)?;
347
348        // Zero the key
349        key.zeroize();
350
351        // Create parent directories
352        if let Some(parent) = self.path.parent() {
353            std::fs::create_dir_all(parent)?;
354        }
355
356        // Build file contents: version || salt || ciphertext
357        let mut file_data = Vec::with_capacity(1 + SALT_SIZE + ciphertext.len());
358        file_data.push(FILE_FORMAT_VERSION);
359        file_data.extend_from_slice(&salt);
360        file_data.extend_from_slice(&ciphertext);
361
362        // Write atomically using temp file
363        let temp_path = self.path.with_extension("tmp");
364        std::fs::write(&temp_path, &file_data)?;
365        std::fs::rename(&temp_path, &self.path)?;
366
367        // Set restrictive permissions on Unix
368        #[cfg(unix)]
369        {
370            use std::os::unix::fs::PermissionsExt;
371            let permissions = std::fs::Permissions::from_mode(0o600);
372            std::fs::set_permissions(&self.path, permissions)?;
373        }
374
375        Ok(())
376    }
377
378    fn load(&self) -> StorageResult<[u8; 32]> {
379        if !self.path.exists() {
380            return Err(StorageError::NotFound);
381        }
382
383        let password = Self::get_password()?;
384        let file_data = std::fs::read(&self.path)?;
385
386        // Parse file format
387        if file_data.is_empty() {
388            return Err(StorageError::InvalidFormat("Empty file".to_string()));
389        }
390
391        let version = file_data[0];
392        if version != FILE_FORMAT_VERSION {
393            return Err(StorageError::InvalidFormat(format!(
394                "Unsupported file format version: {version}"
395            )));
396        }
397
398        if file_data.len() < 1 + SALT_SIZE + 12 + 16 {
399            return Err(StorageError::InvalidFormat("File too short".to_string()));
400        }
401
402        let salt = &file_data[1..1 + SALT_SIZE];
403        let ciphertext = &file_data[1 + SALT_SIZE..];
404
405        // Derive key and decrypt
406        let mut key = Self::derive_key_from_password(&password, salt)?;
407        let result = Self::decrypt(&key, ciphertext);
408
409        // Zero the key
410        key.zeroize();
411
412        result
413    }
414
415    fn delete(&self) -> StorageResult<()> {
416        if self.path.exists() {
417            // Overwrite with zeros before deleting (defense in depth)
418            if let Ok(metadata) = std::fs::metadata(&self.path) {
419                let zeros = vec![0u8; metadata.len() as usize];
420                let _ = std::fs::write(&self.path, &zeros);
421            }
422            std::fs::remove_file(&self.path)?;
423        }
424        Ok(())
425    }
426
427    fn exists(&self) -> bool {
428        self.path.exists()
429    }
430
431    fn backend_name(&self) -> &'static str {
432        "EncryptedFile"
433    }
434
435    fn security_level(&self) -> StorageSecurityLevel {
436        StorageSecurityLevel::Encrypted
437    }
438}
439
440// =============================================================================
441// Cross-Platform Keyring Storage
442// =============================================================================
443
444/// Cross-platform keyring storage using the `keyring` crate
445///
446/// Supports:
447/// - macOS: Keychain Services
448/// - Linux: Secret Service (GNOME Keyring, KWallet)
449/// - Windows: Credential Manager
450pub struct KeyringStorage {
451    service: &'static str,
452    username: &'static str,
453}
454
455impl KeyringStorage {
456    const SERVICE: &'static str = "ant-quic";
457    const USERNAME: &'static str = "hostkey";
458
459    /// Create a new keyring storage instance
460    pub fn new() -> StorageResult<Self> {
461        // Verify keyring is available by trying to create an entry
462        let _ = keyring::Entry::new(Self::SERVICE, Self::USERNAME)
463            .map_err(|e| StorageError::KeychainError(format!("Keyring unavailable: {e}")))?;
464        Ok(Self {
465            service: Self::SERVICE,
466            username: Self::USERNAME,
467        })
468    }
469
470    /// Check if keyring is available on this platform
471    pub fn is_available() -> bool {
472        keyring::Entry::new(Self::SERVICE, Self::USERNAME).is_ok()
473    }
474
475    /// Get the keyring entry
476    fn entry(&self) -> StorageResult<keyring::Entry> {
477        keyring::Entry::new(self.service, self.username)
478            .map_err(|e| StorageError::KeychainError(e.to_string()))
479    }
480}
481
482impl HostKeyStorage for KeyringStorage {
483    fn store(&self, hostkey: &[u8; 32]) -> StorageResult<()> {
484        let entry = self.entry()?;
485        // Store as hex string (keyring stores strings)
486        let hex = hex::encode(hostkey);
487        entry
488            .set_password(&hex)
489            .map_err(|e| StorageError::KeychainError(e.to_string()))
490    }
491
492    fn load(&self) -> StorageResult<[u8; 32]> {
493        let entry = self.entry()?;
494        let hex = entry.get_password().map_err(|e| match e {
495            keyring::Error::NoEntry => StorageError::NotFound,
496            _ => StorageError::KeychainError(e.to_string()),
497        })?;
498
499        let bytes = hex::decode(&hex).map_err(|e| StorageError::InvalidFormat(e.to_string()))?;
500
501        if bytes.len() != 32 {
502            return Err(StorageError::InvalidFormat(format!(
503                "Expected 32 bytes, got {}",
504                bytes.len()
505            )));
506        }
507
508        let mut result = [0u8; 32];
509        result.copy_from_slice(&bytes);
510        Ok(result)
511    }
512
513    fn delete(&self) -> StorageResult<()> {
514        let entry = self.entry()?;
515        match entry.delete_credential() {
516            Ok(()) => Ok(()),
517            Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
518            Err(e) => Err(StorageError::KeychainError(e.to_string())),
519        }
520    }
521
522    fn exists(&self) -> bool {
523        self.entry()
524            .map(|e| e.get_password().is_ok())
525            .unwrap_or(false)
526    }
527
528    fn backend_name(&self) -> &'static str {
529        #[cfg(target_os = "macos")]
530        {
531            "macOS-Keychain"
532        }
533        #[cfg(target_os = "linux")]
534        {
535            "Linux-SecretService"
536        }
537        #[cfg(target_os = "windows")]
538        {
539            "Windows-CredentialManager"
540        }
541        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
542        {
543            "Keyring"
544        }
545    }
546
547    fn security_level(&self) -> StorageSecurityLevel {
548        StorageSecurityLevel::Secure
549    }
550}
551
552// =============================================================================
553// Plain File Storage (Insecure Fallback)
554// =============================================================================
555
556/// Plain file storage with file permission protection only
557///
558/// **SECURITY WARNING**: This stores the HostKey unencrypted!
559/// Anyone with file access can read and copy your identity.
560///
561/// Use only when:
562/// - Platform keychain is unavailable
563/// - You haven't set `ANTQ_HOSTKEY_PASSWORD`
564///
565/// File location: `~/.config/ant-quic/hostkey.key`
566pub struct PlainFileStorage {
567    path: PathBuf,
568}
569
570impl PlainFileStorage {
571    /// Create a new plain file storage at the default location
572    pub fn new() -> StorageResult<Self> {
573        let path = Self::default_path()?;
574        Ok(Self { path })
575    }
576
577    /// Create plain file storage at a custom path
578    pub fn with_path(path: PathBuf) -> Self {
579        Self { path }
580    }
581
582    /// Get the default storage path
583    fn default_path() -> StorageResult<PathBuf> {
584        let config_dir = dirs::config_dir().ok_or_else(|| {
585            StorageError::IoError(std::io::Error::new(
586                std::io::ErrorKind::NotFound,
587                "Could not determine config directory",
588            ))
589        })?;
590        Ok(config_dir.join("ant-quic").join("hostkey.key"))
591    }
592}
593
594impl HostKeyStorage for PlainFileStorage {
595    fn store(&self, hostkey: &[u8; 32]) -> StorageResult<()> {
596        // Create parent directories
597        if let Some(parent) = self.path.parent() {
598            std::fs::create_dir_all(parent)?;
599        }
600
601        // Write atomically using temp file
602        let temp_path = self.path.with_extension("tmp");
603        std::fs::write(&temp_path, hostkey)?;
604        std::fs::rename(&temp_path, &self.path)?;
605
606        // Set restrictive permissions (0600 on Unix)
607        #[cfg(unix)]
608        {
609            use std::os::unix::fs::PermissionsExt;
610            let permissions = std::fs::Permissions::from_mode(0o600);
611            std::fs::set_permissions(&self.path, permissions)?;
612        }
613
614        Ok(())
615    }
616
617    fn load(&self) -> StorageResult<[u8; 32]> {
618        if !self.path.exists() {
619            return Err(StorageError::NotFound);
620        }
621
622        let data = std::fs::read(&self.path)?;
623        if data.len() != 32 {
624            return Err(StorageError::InvalidFormat(format!(
625                "Expected 32 bytes, got {}",
626                data.len()
627            )));
628        }
629
630        let mut result = [0u8; 32];
631        result.copy_from_slice(&data);
632        Ok(result)
633    }
634
635    fn delete(&self) -> StorageResult<()> {
636        if self.path.exists() {
637            // Overwrite with zeros before deleting (defense in depth)
638            let _ = std::fs::write(&self.path, [0u8; 32]);
639            std::fs::remove_file(&self.path)?;
640        }
641        Ok(())
642    }
643
644    fn exists(&self) -> bool {
645        self.path.exists()
646    }
647
648    fn backend_name(&self) -> &'static str {
649        "PlainFile-INSECURE"
650    }
651
652    fn security_level(&self) -> StorageSecurityLevel {
653        StorageSecurityLevel::Insecure
654    }
655}
656
657// =============================================================================
658// Storage Selection Result
659// =============================================================================
660
661/// Result of auto-selecting storage, includes security info
662pub struct StorageSelection {
663    /// The selected storage backend
664    pub storage: Box<dyn HostKeyStorage>,
665    /// Security level of the selected backend
666    pub security_level: StorageSecurityLevel,
667}
668
669// =============================================================================
670// Auto-Selection
671// =============================================================================
672
673/// Automatically select the best available storage backend for this platform
674///
675/// Priority order:
676/// 1. Platform keychain (via `keyring` crate) - Secure, zero-config
677/// 2. Encrypted file (if `ANTQ_HOSTKEY_PASSWORD` env var set)
678/// 3. Plain file with warning (zero-config fallback)
679pub fn auto_storage() -> StorageResult<StorageSelection> {
680    // 1. Try platform keychain first
681    if KeyringStorage::is_available() {
682        if let Ok(storage) = KeyringStorage::new() {
683            let security_level = storage.security_level();
684            return Ok(StorageSelection {
685                storage: Box::new(storage),
686                security_level,
687            });
688        }
689    }
690
691    // 2. Try encrypted file if password is available
692    if std::env::var("ANTQ_HOSTKEY_PASSWORD").is_ok() {
693        let storage = EncryptedFileStorage::new()?;
694        return Ok(StorageSelection {
695            storage: Box::new(storage),
696            security_level: StorageSecurityLevel::Encrypted,
697        });
698    }
699
700    // 3. Fall back to plain file with warning
701    let storage = PlainFileStorage::new()?;
702    Ok(StorageSelection {
703        storage: Box::new(storage),
704        security_level: StorageSecurityLevel::Insecure,
705    })
706}
707
708/// Legacy function for backwards compatibility - returns just the storage
709#[deprecated(
710    since = "0.15.0",
711    note = "Use auto_storage() which returns StorageSelection"
712)]
713pub fn auto_storage_legacy() -> StorageResult<Box<dyn HostKeyStorage>> {
714    Ok(auto_storage()?.storage)
715}
716
717/// Get encrypted file storage directly (useful for testing or when env var is available)
718pub fn encrypted_file_storage() -> StorageResult<EncryptedFileStorage> {
719    EncryptedFileStorage::new()
720}
721
722// =============================================================================
723// Tests
724// =============================================================================
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use std::sync::Mutex;
730    use tempfile::TempDir;
731
732    // Mutex to serialize tests that modify ANTQ_HOSTKEY_PASSWORD env var
733    static ENV_VAR_MUTEX: Mutex<()> = Mutex::new(());
734
735    // Helper to safely set/remove password env var within mutex guard
736    fn with_password<T, F: FnOnce() -> T>(password: Option<&str>, f: F) -> T {
737        let _guard = ENV_VAR_MUTEX.lock().expect("ENV_VAR_MUTEX poisoned");
738        // SAFETY: We hold the mutex, so no concurrent env var access
739        unsafe {
740            if let Some(pwd) = password {
741                std::env::set_var("ANTQ_HOSTKEY_PASSWORD", pwd);
742            } else {
743                std::env::remove_var("ANTQ_HOSTKEY_PASSWORD");
744            }
745        }
746        let result = f();
747        // Clean up
748        unsafe {
749            std::env::remove_var("ANTQ_HOSTKEY_PASSWORD");
750        }
751        result
752    }
753
754    #[test]
755    fn test_encrypted_file_storage_roundtrip() {
756        with_password(Some("test-password-12345"), || {
757            let temp_dir = TempDir::new().expect("Failed to create temp dir");
758            let path = temp_dir.path().join("hostkey.enc");
759            let storage = EncryptedFileStorage::with_path(path);
760
761            let hostkey = [0xAB; 32];
762
763            // Store
764            storage.store(&hostkey).expect("Failed to store");
765
766            // Load
767            let loaded = storage.load().expect("Failed to load");
768            assert_eq!(loaded, hostkey);
769        });
770    }
771
772    #[test]
773    fn test_encrypted_file_storage_wrong_password() {
774        // First store with correct password
775        let temp_dir = TempDir::new().expect("Failed to create temp dir");
776        let path = temp_dir.path().join("hostkey.enc");
777
778        with_password(Some("correct-password"), || {
779            let storage = EncryptedFileStorage::with_path(path.clone());
780            let hostkey = [0xAB; 32];
781            storage.store(&hostkey).expect("Failed to store");
782        });
783
784        // Then try to load with wrong password
785        with_password(Some("wrong-password"), || {
786            let storage = EncryptedFileStorage::with_path(path.clone());
787            let result = storage.load();
788            assert!(result.is_err(), "Should fail with wrong password");
789        });
790    }
791
792    #[test]
793    fn test_encrypted_file_storage_missing_password() {
794        with_password(None, || {
795            let temp_dir = TempDir::new().expect("Failed to create temp dir");
796            let path = temp_dir.path().join("hostkey.enc");
797            let storage = EncryptedFileStorage::with_path(path);
798
799            let hostkey = [0xCD; 32];
800            let result = storage.store(&hostkey);
801
802            assert!(matches!(result, Err(StorageError::PasswordRequired)));
803        });
804    }
805
806    #[test]
807    fn test_encrypted_file_storage_not_found() {
808        with_password(Some("test-password"), || {
809            let temp_dir = TempDir::new().expect("Failed to create temp dir");
810            let path = temp_dir.path().join("nonexistent.enc");
811            let storage = EncryptedFileStorage::with_path(path);
812
813            let result = storage.load();
814            assert!(matches!(result, Err(StorageError::NotFound)));
815        });
816    }
817
818    #[test]
819    fn test_encrypted_file_storage_delete() {
820        with_password(Some("test-password"), || {
821            let temp_dir = TempDir::new().expect("Failed to create temp dir");
822            let path = temp_dir.path().join("hostkey.enc");
823            let storage = EncryptedFileStorage::with_path(path.clone());
824
825            let hostkey = [0xEF; 32];
826            storage.store(&hostkey).expect("Failed to store");
827            assert!(path.exists());
828
829            storage.delete().expect("Failed to delete");
830            assert!(!path.exists());
831        });
832    }
833
834    #[test]
835    fn test_key_derivation_deterministic() {
836        let password = "test-password";
837        let salt = [1u8; SALT_SIZE];
838
839        let key1 = EncryptedFileStorage::derive_key_from_password(password, &salt)
840            .expect("Key derivation failed");
841        let key2 = EncryptedFileStorage::derive_key_from_password(password, &salt)
842            .expect("Key derivation failed");
843
844        assert_eq!(key1, key2);
845    }
846
847    #[test]
848    fn test_different_salts_different_keys() {
849        let password = "test-password";
850        let salt1 = [1u8; SALT_SIZE];
851        let salt2 = [2u8; SALT_SIZE];
852
853        let key1 = EncryptedFileStorage::derive_key_from_password(password, &salt1)
854            .expect("Key derivation failed");
855        let key2 = EncryptedFileStorage::derive_key_from_password(password, &salt2)
856            .expect("Key derivation failed");
857
858        assert_ne!(key1, key2);
859    }
860
861    #[test]
862    fn test_encryption_roundtrip() {
863        let key = [0x42; 32];
864        let plaintext = [0xAB; 32];
865
866        let ciphertext =
867            EncryptedFileStorage::encrypt(&key, &plaintext).expect("Encryption failed");
868
869        // Ciphertext should be larger than plaintext (nonce + tag)
870        assert!(ciphertext.len() > 32);
871
872        let decrypted =
873            EncryptedFileStorage::decrypt(&key, &ciphertext).expect("Decryption failed");
874
875        assert_eq!(decrypted, plaintext);
876    }
877
878    #[test]
879    fn test_wrong_key_fails_decryption() {
880        let key1 = [0x42; 32];
881        let key2 = [0x43; 32];
882        let plaintext = [0xAB; 32];
883
884        let ciphertext =
885            EncryptedFileStorage::encrypt(&key1, &plaintext).expect("Encryption failed");
886
887        let result = EncryptedFileStorage::decrypt(&key2, &ciphertext);
888        assert!(result.is_err());
889    }
890
891    // =========================================================================
892    // PlainFileStorage Tests
893    // =========================================================================
894
895    #[test]
896    fn test_plain_file_storage_roundtrip() {
897        let temp_dir = TempDir::new().expect("Failed to create temp dir");
898        let path = temp_dir.path().join("hostkey.key");
899        let storage = PlainFileStorage::with_path(path);
900
901        let hostkey = [0xAB; 32];
902
903        // Store
904        storage.store(&hostkey).expect("Failed to store");
905
906        // Load
907        let loaded = storage.load().expect("Failed to load");
908        assert_eq!(loaded, hostkey);
909    }
910
911    #[test]
912    fn test_plain_file_storage_not_found() {
913        let temp_dir = TempDir::new().expect("Failed to create temp dir");
914        let path = temp_dir.path().join("nonexistent.key");
915        let storage = PlainFileStorage::with_path(path);
916
917        let result = storage.load();
918        assert!(matches!(result, Err(StorageError::NotFound)));
919    }
920
921    #[test]
922    fn test_plain_file_storage_delete() {
923        let temp_dir = TempDir::new().expect("Failed to create temp dir");
924        let path = temp_dir.path().join("hostkey.key");
925        let storage = PlainFileStorage::with_path(path.clone());
926
927        let hostkey = [0xEF; 32];
928        storage.store(&hostkey).expect("Failed to store");
929        assert!(path.exists());
930
931        storage.delete().expect("Failed to delete");
932        assert!(!path.exists());
933    }
934
935    #[test]
936    fn test_plain_file_storage_exists() {
937        let temp_dir = TempDir::new().expect("Failed to create temp dir");
938        let path = temp_dir.path().join("hostkey.key");
939        let storage = PlainFileStorage::with_path(path);
940
941        assert!(!storage.exists());
942
943        let hostkey = [0xAB; 32];
944        storage.store(&hostkey).expect("Failed to store");
945        assert!(storage.exists());
946    }
947
948    #[test]
949    fn test_plain_file_storage_security_level() {
950        let temp_dir = TempDir::new().expect("Failed to create temp dir");
951        let path = temp_dir.path().join("hostkey.key");
952        let storage = PlainFileStorage::with_path(path);
953
954        assert_eq!(storage.security_level(), StorageSecurityLevel::Insecure);
955        assert!(storage.security_level().warning_message().is_some());
956    }
957
958    #[cfg(unix)]
959    #[test]
960    fn test_plain_file_storage_permissions() {
961        use std::os::unix::fs::PermissionsExt;
962
963        let temp_dir = TempDir::new().expect("Failed to create temp dir");
964        let path = temp_dir.path().join("hostkey.key");
965        let storage = PlainFileStorage::with_path(path.clone());
966
967        let hostkey = [0xAB; 32];
968        storage.store(&hostkey).expect("Failed to store");
969
970        let metadata = std::fs::metadata(&path).expect("Failed to get metadata");
971        let permissions = metadata.permissions();
972
973        // Should be 0600 (owner read/write only)
974        assert_eq!(permissions.mode() & 0o777, 0o600);
975    }
976
977    #[test]
978    fn test_plain_file_storage_invalid_size() {
979        let temp_dir = TempDir::new().expect("Failed to create temp dir");
980        let path = temp_dir.path().join("hostkey.key");
981
982        // Write invalid data (wrong size)
983        std::fs::write(&path, [0u8; 16]).expect("Failed to write");
984
985        let storage = PlainFileStorage::with_path(path);
986        let result = storage.load();
987        assert!(matches!(result, Err(StorageError::InvalidFormat(_))));
988    }
989
990    // =========================================================================
991    // KeyringStorage Tests (require system keyring, may be ignored in CI)
992    // =========================================================================
993
994    #[test]
995    #[ignore = "Requires system keyring daemon (run manually)"]
996    fn test_keyring_storage_roundtrip() {
997        if !KeyringStorage::is_available() {
998            println!("Keyring not available, skipping test");
999            return;
1000        }
1001
1002        let storage = KeyringStorage::new().expect("Failed to create keyring storage");
1003
1004        // Clean up any existing entry first
1005        let _ = storage.delete();
1006
1007        let hostkey = [0xAB; 32];
1008
1009        // Store
1010        storage.store(&hostkey).expect("Failed to store");
1011
1012        // Load
1013        let loaded = storage.load().expect("Failed to load");
1014        assert_eq!(loaded, hostkey);
1015
1016        // Cleanup
1017        storage.delete().expect("Failed to delete");
1018    }
1019
1020    #[test]
1021    #[ignore = "Requires system keyring daemon (run manually)"]
1022    fn test_keyring_storage_not_found() {
1023        if !KeyringStorage::is_available() {
1024            println!("Keyring not available, skipping test");
1025            return;
1026        }
1027
1028        let storage = KeyringStorage::new().expect("Failed to create keyring storage");
1029
1030        // Clean up any existing entry first
1031        let _ = storage.delete();
1032
1033        let result = storage.load();
1034        assert!(matches!(result, Err(StorageError::NotFound)));
1035    }
1036
1037    #[test]
1038    #[ignore = "Requires system keyring daemon (run manually)"]
1039    fn test_keyring_storage_security_level() {
1040        if !KeyringStorage::is_available() {
1041            println!("Keyring not available, skipping test");
1042            return;
1043        }
1044
1045        let storage = KeyringStorage::new().expect("Failed to create keyring storage");
1046        assert_eq!(storage.security_level(), StorageSecurityLevel::Secure);
1047        assert!(storage.security_level().warning_message().is_none());
1048    }
1049
1050    // =========================================================================
1051    // StorageSecurityLevel Tests
1052    // =========================================================================
1053
1054    #[test]
1055    fn test_security_level_warning_messages() {
1056        assert!(StorageSecurityLevel::Secure.warning_message().is_none());
1057        assert!(StorageSecurityLevel::Encrypted.warning_message().is_none());
1058        assert!(StorageSecurityLevel::Insecure.warning_message().is_some());
1059    }
1060
1061    #[test]
1062    fn test_security_level_is_secure() {
1063        assert!(StorageSecurityLevel::Secure.is_secure());
1064        assert!(StorageSecurityLevel::Encrypted.is_secure());
1065        assert!(!StorageSecurityLevel::Insecure.is_secure());
1066    }
1067
1068    // =========================================================================
1069    // auto_storage Tests
1070    // =========================================================================
1071
1072    #[test]
1073    fn test_auto_storage_fallback_to_plain_file() {
1074        // Without password and without keyring, should fall back to plain file
1075        with_password(None, || {
1076            // This test may succeed with keyring if available,
1077            // but should at least not fail
1078            let result = auto_storage();
1079            assert!(result.is_ok());
1080            let selection = result.expect("auto_storage should succeed");
1081            // Should be either Secure (keyring) or Insecure (plain file)
1082            assert!(
1083                selection.security_level == StorageSecurityLevel::Secure
1084                    || selection.security_level == StorageSecurityLevel::Insecure
1085            );
1086        });
1087    }
1088
1089    #[test]
1090    fn test_auto_storage_with_password() {
1091        with_password(Some("test-password"), || {
1092            // With password, if keyring not available, should use encrypted file
1093            let result = auto_storage();
1094            assert!(result.is_ok());
1095            let selection = result.expect("auto_storage should succeed");
1096            // Should be Secure (keyring) or Encrypted (file with password)
1097            assert!(
1098                selection.security_level == StorageSecurityLevel::Secure
1099                    || selection.security_level == StorageSecurityLevel::Encrypted
1100            );
1101        });
1102    }
1103}