Skip to main content

aion_context/
keystore.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Key management with OS keyring integration and file-based fallback
3//!
4//! This module provides secure key storage using the operating system's
5//! credential management facilities (macOS Keychain, Windows Credential Manager,
6//! Linux Secret Service). When OS keyring is unavailable, falls back to
7//! encrypted file-based storage in `~/.aion/keys/`.
8//!
9//! # Security Properties
10//!
11//! - **OS-level protection**: Keys protected by OS encryption when available
12//! - **Encrypted file fallback**: ChaCha20-Poly1305 encrypted files when keyring unavailable
13//! - **Automatic zeroization**: Key material cleared from memory after use
14//! - **Password-encrypted export**: Backup keys with ChaCha20-Poly1305 encryption
15//! - **No plaintext storage**: Keys never written to disk unencrypted
16//!
17//! # Usage
18//!
19//! ```no_run
20//! use aion_context::keystore::KeyStore;
21//! use aion_context::types::AuthorId;
22//!
23//! # fn example() -> aion_context::Result<()> {
24//! let keystore = KeyStore::new();
25//!
26//! // Generate and store a new keypair
27//! let (signing_key, verifying_key) = keystore.generate_keypair(AuthorId::new(50001))?;
28//!
29//! // Load keypair later
30//! let signing_key = keystore.load_signing_key(AuthorId::new(50001))?;
31//! # Ok(())
32//! # }
33//! ```
34
35use crate::crypto::{decrypt, encrypt, generate_nonce, SigningKey, VerifyingKey};
36use crate::types::AuthorId;
37use crate::{AionError, Result};
38use rand::RngCore;
39use std::path::PathBuf;
40
41/// Service name for keyring entries
42const KEYRING_SERVICE: &str = "aion-v2";
43
44/// Magic bytes for encrypted key files
45const EXPORT_MAGIC: &[u8; 4] = b"AKEY";
46
47/// Export file version (v2 uses Argon2 instead of BLAKE3)
48const EXPORT_VERSION: u8 = 2;
49
50/// Salt size for Argon2 key derivation
51const SALT_SIZE: usize = 16;
52
53/// File-based key storage directory name
54const KEYS_DIR: &str = "keys";
55
56/// File extension for encrypted key files
57const KEY_FILE_EXT: &str = ".key";
58
59/// Magic bytes for file-based key storage
60const FILE_KEY_MAGIC: &[u8; 4] = b"AFKY";
61
62/// File key storage version
63const FILE_KEY_VERSION: u8 = 1;
64
65/// Key store for managing Ed25519 keypairs
66///
67/// Uses the OS keyring for secure storage when available, with automatic
68/// fallback to encrypted file-based storage in `~/.aion/keys/`.
69#[derive(Debug)]
70pub struct KeyStore {
71    /// Whether to use file-based storage (fallback mode)
72    use_file_storage: bool,
73    /// Base directory for file-based storage
74    storage_dir: PathBuf,
75}
76
77impl Default for KeyStore {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl KeyStore {
84    /// Create a new key store
85    ///
86    /// Automatically detects whether OS keyring is available and falls back
87    /// to file-based storage if not.
88    #[must_use]
89    pub fn new() -> Self {
90        let storage_dir = get_aion_keys_dir();
91        let use_file_storage = !is_keyring_available();
92
93        Self {
94            use_file_storage,
95            storage_dir,
96        }
97    }
98
99    /// Create a key store that always uses file-based storage
100    ///
101    /// Useful for testing or environments where keyring access is restricted.
102    #[must_use]
103    pub fn file_based() -> Self {
104        Self {
105            use_file_storage: true,
106            storage_dir: get_aion_keys_dir(),
107        }
108    }
109
110    /// Create a key store with a custom storage directory
111    ///
112    /// Useful for testing with isolated storage.
113    #[must_use]
114    pub const fn with_storage_dir(storage_dir: PathBuf) -> Self {
115        Self {
116            use_file_storage: true,
117            storage_dir,
118        }
119    }
120
121    /// Generate a new keypair and store it in the OS keyring
122    ///
123    /// # Errors
124    ///
125    /// Returns error if keyring access fails
126    pub fn generate_keypair(&self, author_id: AuthorId) -> Result<(SigningKey, VerifyingKey)> {
127        let signing_key = SigningKey::generate();
128        let verifying_key = signing_key.verifying_key();
129
130        self.store_signing_key(author_id, &signing_key)?;
131
132        tracing::info!(
133            event = "keystore_key_created",
134            author = %crate::obs::author_short(author_id),
135            backend = if self.use_file_storage { "file" } else { "os_keyring" },
136        );
137        Ok((signing_key, verifying_key))
138    }
139
140    /// Store a signing key
141    ///
142    /// Uses OS keyring when available, otherwise falls back to encrypted file storage.
143    ///
144    /// # Errors
145    ///
146    /// Returns error if storage fails
147    pub fn store_signing_key(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
148        if self.use_file_storage {
149            self.store_key_to_file(author_id, key)
150        } else {
151            self.store_key_to_keyring(author_id, key)
152        }
153    }
154
155    /// Store a key in the OS keyring
156    fn store_key_to_keyring(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
157        let entry = self.get_entry(author_id)?;
158        let key_hex = hex::encode(key.to_bytes());
159
160        entry
161            .set_password(&key_hex)
162            .map_err(|e| AionError::KeyringError {
163                operation: "store".to_string(),
164                reason: e.to_string(),
165            })?;
166
167        Ok(())
168    }
169
170    /// Store a key to encrypted file
171    fn store_key_to_file(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
172        // Ensure keys directory exists
173        std::fs::create_dir_all(&self.storage_dir).map_err(|e| AionError::KeyringError {
174            operation: "create_dir".to_string(),
175            reason: e.to_string(),
176        })?;
177
178        let file_path = self.get_key_file_path(author_id);
179
180        // Encrypt key with machine-specific key
181        let encrypted = encrypt_key_for_storage(author_id, key)?;
182
183        // Write atomically via temp file
184        let temp_path = file_path.with_extension("tmp");
185        std::fs::write(&temp_path, &encrypted).map_err(|e| AionError::KeyringError {
186            operation: "write".to_string(),
187            reason: e.to_string(),
188        })?;
189
190        std::fs::rename(&temp_path, &file_path).map_err(|e| AionError::KeyringError {
191            operation: "rename".to_string(),
192            reason: e.to_string(),
193        })?;
194
195        // Set restrictive permissions on Unix
196        #[cfg(unix)]
197        {
198            use std::os::unix::fs::PermissionsExt;
199            let perms = std::fs::Permissions::from_mode(0o600);
200            std::fs::set_permissions(&file_path, perms).map_err(|e| AionError::KeyringError {
201                operation: "chmod".to_string(),
202                reason: e.to_string(),
203            })?;
204        }
205
206        Ok(())
207    }
208
209    /// Load a signing key
210    ///
211    /// Uses OS keyring when available, otherwise loads from encrypted file storage.
212    ///
213    /// # Errors
214    ///
215    /// Returns error if key not found or access fails
216    pub fn load_signing_key(&self, author_id: AuthorId) -> Result<SigningKey> {
217        let result = if self.use_file_storage {
218            self.load_key_from_file(author_id)
219        } else {
220            self.load_key_from_keyring(author_id)
221        };
222        if let Err(ref e) = result {
223            tracing::warn!(
224                event = "keystore_load_rejected",
225                author = %crate::obs::author_short(author_id),
226                reason = match e {
227                    AionError::KeyNotFound { .. } => "key_not_found",
228                    AionError::InvalidPrivateKey { .. } => "invalid_key_bytes",
229                    AionError::KeyringError { .. } => "keyring_error",
230                    _ => "load_error",
231                },
232            );
233        }
234        result
235    }
236
237    /// Load a key from OS keyring
238    fn load_key_from_keyring(&self, author_id: AuthorId) -> Result<SigningKey> {
239        let entry = self.get_entry(author_id)?;
240
241        let key_hex = entry.get_password().map_err(|e| AionError::KeyNotFound {
242            author_id,
243            reason: e.to_string(),
244        })?;
245
246        let key_bytes = hex::decode(&key_hex).map_err(|e| AionError::InvalidPrivateKey {
247            reason: format!("invalid hex in keyring: {e}"),
248        })?;
249
250        SigningKey::from_bytes(&key_bytes)
251    }
252
253    /// Load a key from encrypted file
254    fn load_key_from_file(&self, author_id: AuthorId) -> Result<SigningKey> {
255        let file_path = self.get_key_file_path(author_id);
256
257        if !file_path.exists() {
258            return Err(AionError::KeyNotFound {
259                author_id,
260                reason: format!("key file not found: {}", file_path.display()),
261            });
262        }
263
264        let encrypted = std::fs::read(&file_path).map_err(|e| AionError::KeyNotFound {
265            author_id,
266            reason: e.to_string(),
267        })?;
268
269        decrypt_key_from_storage(author_id, &encrypted)
270    }
271
272    /// Delete a signing key
273    ///
274    /// # Errors
275    ///
276    /// Returns error if key not found or access fails
277    pub fn delete_signing_key(&self, author_id: AuthorId) -> Result<()> {
278        if self.use_file_storage {
279            self.delete_key_from_file(author_id)
280        } else {
281            self.delete_key_from_keyring(author_id)
282        }
283    }
284
285    /// Delete a key from OS keyring
286    fn delete_key_from_keyring(&self, author_id: AuthorId) -> Result<()> {
287        let entry = self.get_entry(author_id)?;
288
289        entry
290            .delete_credential()
291            .map_err(|e| AionError::KeyringError {
292                operation: "delete".to_string(),
293                reason: e.to_string(),
294            })?;
295
296        Ok(())
297    }
298
299    /// Delete a key file
300    fn delete_key_from_file(&self, author_id: AuthorId) -> Result<()> {
301        let file_path = self.get_key_file_path(author_id);
302
303        if !file_path.exists() {
304            return Err(AionError::KeyNotFound {
305                author_id,
306                reason: "key file not found".to_string(),
307            });
308        }
309
310        std::fs::remove_file(&file_path).map_err(|e| AionError::KeyringError {
311            operation: "delete".to_string(),
312            reason: e.to_string(),
313        })?;
314
315        Ok(())
316    }
317
318    /// Check if a signing key exists
319    #[must_use]
320    pub fn has_signing_key(&self, author_id: AuthorId) -> bool {
321        if self.use_file_storage {
322            self.get_key_file_path(author_id).exists()
323        } else {
324            self.get_entry(author_id)
325                .and_then(|e| {
326                    e.get_password().map_err(|e| AionError::KeyringError {
327                        operation: "check".to_string(),
328                        reason: e.to_string(),
329                    })
330                })
331                .is_ok()
332        }
333    }
334
335    /// List all stored key IDs (file-based storage only)
336    ///
337    /// Returns author IDs for all keys stored in the keys directory.
338    /// For keyring-based storage, returns an empty list.
339    pub fn list_keys(&self) -> Result<Vec<AuthorId>> {
340        if !self.use_file_storage {
341            // Keyring doesn't support enumeration, return empty
342            return Ok(Vec::new());
343        }
344
345        if !self.storage_dir.exists() {
346            return Ok(Vec::new());
347        }
348
349        let mut keys = Vec::new();
350
351        let entries =
352            std::fs::read_dir(&self.storage_dir).map_err(|e| AionError::KeyringError {
353                operation: "list".to_string(),
354                reason: e.to_string(),
355            })?;
356
357        for entry in entries {
358            let entry = entry.map_err(|e| AionError::KeyringError {
359                operation: "list".to_string(),
360                reason: e.to_string(),
361            })?;
362
363            let path = entry.path();
364            if let Some(ext) = path.extension() {
365                if ext == "key" {
366                    if let Some(stem) = path.file_stem() {
367                        if let Some(stem_str) = stem.to_str() {
368                            if let Some(id_str) = stem_str.strip_prefix("author-") {
369                                if let Ok(id) = id_str.parse::<u64>() {
370                                    keys.push(AuthorId::new(id));
371                                }
372                            }
373                        }
374                    }
375                }
376            }
377        }
378
379        keys.sort_by_key(|k| k.as_u64());
380        Ok(keys)
381    }
382
383    /// Get the file path for a key
384    fn get_key_file_path(&self, author_id: AuthorId) -> PathBuf {
385        self.storage_dir
386            .join(format!("author-{}{}", author_id.as_u64(), KEY_FILE_EXT))
387    }
388
389    /// Export a signing key with password encryption
390    ///
391    /// Returns encrypted bytes that can be written to a file for backup.
392    /// Format: MAGIC (4) + VERSION (1) + SALT (16) + NONCE (12) + CIPHERTEXT (32+16)
393    ///
394    /// Uses Argon2id for password-based key derivation (memory-hard, resistant to
395    /// GPU/ASIC attacks) and ChaCha20-Poly1305 for authenticated encryption.
396    ///
397    /// # Errors
398    ///
399    /// Returns error if key not found or encryption fails
400    #[allow(clippy::arithmetic_side_effects)] // Fixed size components
401    pub fn export_encrypted(&self, author_id: AuthorId, password: &str) -> Result<Vec<u8>> {
402        let signing_key = self.load_signing_key(author_id)?;
403
404        // Generate random salt for Argon2
405        let salt = generate_salt();
406
407        // Derive encryption key from password using Argon2id
408        let encryption_key = derive_key_from_password(password, &salt)?;
409
410        // Encrypt the key bytes
411        let nonce = generate_nonce();
412        let aad = author_id.as_u64().to_le_bytes();
413        let ciphertext = encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad)?;
414
415        // Build export format: MAGIC + VERSION + SALT + NONCE + CIPHERTEXT
416        let mut output = Vec::with_capacity(4 + 1 + SALT_SIZE + 12 + ciphertext.len());
417        output.extend_from_slice(EXPORT_MAGIC);
418        output.push(EXPORT_VERSION);
419        output.extend_from_slice(&salt);
420        output.extend_from_slice(&nonce);
421        output.extend_from_slice(&ciphertext);
422
423        Ok(output)
424    }
425
426    /// Import a signing key from password-encrypted bytes
427    ///
428    /// Decrypts a key file created by `export_encrypted` and stores the key
429    /// in the OS keyring.
430    ///
431    /// # Errors
432    ///
433    /// Returns error if:
434    /// - File format is invalid (wrong magic, unsupported version)
435    /// - Decryption fails (wrong password, corrupted data)
436    /// - Key storage fails
437    pub fn import_encrypted(
438        &self,
439        author_id: AuthorId,
440        password: &str,
441        encrypted_data: &[u8],
442    ) -> Result<SigningKey> {
443        let parsed = parse_encrypted_key_blob(encrypted_data)?;
444        let encryption_key = derive_key_from_password(password, &parsed.salt)?;
445        let aad = author_id.as_u64().to_le_bytes();
446        let key_bytes = decrypt(&encryption_key, &parsed.nonce, parsed.ciphertext, &aad)?;
447        let signing_key = SigningKey::from_bytes(&key_bytes)?;
448        self.store_signing_key(author_id, &signing_key)?;
449        Ok(signing_key)
450    }
451
452    /// Get keyring entry for an author
453    #[allow(clippy::unused_self)] // Method for API consistency
454    fn get_entry(&self, author_id: AuthorId) -> Result<keyring::Entry> {
455        let username = format!("author-{}", author_id.as_u64());
456        keyring::Entry::new(KEYRING_SERVICE, &username).map_err(|e| AionError::KeyringError {
457            operation: "access".to_string(),
458            reason: e.to_string(),
459        })
460    }
461}
462
463/// Parsed encrypted-key blob layout: magic(4) + version(1) + salt(16) + nonce(12) + ciphertext.
464struct ParsedEncryptedKey<'a> {
465    salt: [u8; SALT_SIZE],
466    nonce: [u8; 12],
467    ciphertext: &'a [u8],
468}
469
470fn parse_encrypted_key_blob(encrypted_data: &[u8]) -> Result<ParsedEncryptedKey<'_>> {
471    const MIN_SIZE: usize = 4 + 1 + SALT_SIZE + 12 + 32 + 16;
472    if encrypted_data.len() < MIN_SIZE {
473        return Err(AionError::InvalidFormat {
474            reason: format!(
475                "encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
476                encrypted_data.len()
477            ),
478        });
479    }
480    let magic = encrypted_data
481        .get(0..4)
482        .ok_or_else(|| AionError::InvalidFormat {
483            reason: "missing magic".to_string(),
484        })?;
485    if magic != EXPORT_MAGIC {
486        return Err(AionError::InvalidFormat {
487            reason: "invalid key file magic".to_string(),
488        });
489    }
490    let version = *encrypted_data
491        .get(4)
492        .ok_or_else(|| AionError::InvalidFormat {
493            reason: "missing version byte".to_string(),
494        })?;
495    if version != EXPORT_VERSION {
496        return Err(AionError::InvalidFormat {
497            reason: format!("unsupported key file version: {version} (expected: {EXPORT_VERSION})"),
498        });
499    }
500    let salt_end = 5_usize.saturating_add(SALT_SIZE);
501    let salt: [u8; SALT_SIZE] = encrypted_data
502        .get(5..salt_end)
503        .and_then(|s| s.try_into().ok())
504        .ok_or_else(|| AionError::InvalidFormat {
505            reason: "invalid salt".to_string(),
506        })?;
507    let nonce_end = salt_end.saturating_add(12);
508    let nonce: [u8; 12] = encrypted_data
509        .get(salt_end..nonce_end)
510        .and_then(|s| s.try_into().ok())
511        .ok_or_else(|| AionError::InvalidFormat {
512            reason: "invalid nonce".to_string(),
513        })?;
514    let ciphertext = encrypted_data
515        .get(nonce_end..)
516        .ok_or_else(|| AionError::InvalidFormat {
517            reason: "missing ciphertext".to_string(),
518        })?;
519    Ok(ParsedEncryptedKey {
520        salt,
521        nonce,
522        ciphertext,
523    })
524}
525
526/// Generate a random salt for Argon2 key derivation
527fn generate_salt() -> [u8; SALT_SIZE] {
528    let mut salt = [0u8; SALT_SIZE];
529    rand::rngs::OsRng.fill_bytes(&mut salt);
530    salt
531}
532
533/// Derive encryption key from password using Argon2id
534///
535/// Argon2id is a memory-hard password hashing function that provides:
536/// - Resistance to GPU/ASIC brute-force attacks
537/// - Protection against timing attacks
538/// - Configurable memory and time costs
539///
540/// Parameters chosen for balance between security and usability:
541/// - Memory: 64 MiB (`m_cost` = 65536)
542/// - Iterations: 3 (`t_cost` = 3)
543/// - Parallelism: 4 threads (`p_cost` = 4)
544fn derive_key_from_password(password: &str, salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
545    use argon2::{Algorithm, Argon2, Params, Version};
546
547    // Configure Argon2id parameters
548    // These are reasonable defaults for interactive use (< 1 second on modern hardware)
549    let params = Params::new(
550        65536,    // 64 MiB memory
551        3,        // 3 iterations
552        4,        // 4 parallel lanes
553        Some(32), // 32-byte output
554    )
555    .map_err(|e| AionError::InvalidPrivateKey {
556        reason: format!("Argon2 params error: {e}"),
557    })?;
558
559    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
560
561    let mut output = [0u8; 32];
562    argon2
563        .hash_password_into(password.as_bytes(), salt, &mut output)
564        .map_err(|e| AionError::InvalidPrivateKey {
565            reason: format!("Argon2 key derivation failed: {e}"),
566        })?;
567
568    Ok(output)
569}
570
571/// Get the AION keys directory (~/.aion/keys)
572fn get_aion_keys_dir() -> PathBuf {
573    dirs::home_dir()
574        .unwrap_or_else(|| PathBuf::from("."))
575        .join(".aion")
576        .join(KEYS_DIR)
577}
578
579/// Check if OS keyring is available and functional
580fn is_keyring_available() -> bool {
581    // Try to create a test entry - if this fails, keyring isn't available
582    let test_username = "__aion_keyring_test__";
583    let test_entry = keyring::Entry::new(KEYRING_SERVICE, test_username);
584
585    let Ok(entry) = test_entry else {
586        return false;
587    };
588
589    // Try to set a test value
590    let test_value = "aion-keyring-test-12345";
591    if entry.set_password(test_value).is_err() {
592        return false;
593    }
594
595    // Create a NEW entry instance (simulating a different process/invocation)
596    // This tests whether the keyring persists data across entry instances
597    let Ok(entry2) = keyring::Entry::new(KEYRING_SERVICE, test_username) else {
598        let _ = entry.delete_credential();
599        return false;
600    };
601
602    // Verify we can read it back from the new entry instance
603    let result = matches!(entry2.get_password(), Ok(retrieved) if retrieved == test_value);
604
605    // Clean up test entry
606    let _ = entry.delete_credential();
607
608    result
609}
610
611/// Fixed salt for file-based key storage (machine-local protection)
612/// This provides obfuscation but not true security - the real protection
613/// comes from filesystem permissions and the encryption itself
614const FILE_STORAGE_SALT: [u8; SALT_SIZE] = [
615    0x41, 0x49, 0x4f, 0x4e, // "AION"
616    0x76, 0x32, 0x00, 0x00, // "v2\0\0"
617    0x6b, 0x65, 0x79, 0x73, // "keys"
618    0x74, 0x6f, 0x72, 0x65, // "tore"
619];
620
621/// Encrypt a key for file-based storage
622///
623/// Uses a fixed salt with author ID as additional authenticated data.
624/// Security relies on filesystem permissions (0600) for the key files.
625fn encrypt_key_for_storage(author_id: AuthorId, key: &SigningKey) -> Result<Vec<u8>> {
626    // Derive encryption key from machine-specific data
627    // Note: This is weaker than OS keyring but provides obfuscation
628    let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
629
630    let nonce = generate_nonce();
631    let aad = author_id.as_u64().to_le_bytes();
632    let ciphertext = encrypt(&machine_key, &nonce, key.to_bytes(), &aad)?;
633
634    // Format: MAGIC (4) + VERSION (1) + NONCE (12) + CIPHERTEXT
635    #[allow(clippy::arithmetic_side_effects)] // Fixed-size constants
636    let mut output = Vec::with_capacity(4 + 1 + 12 + ciphertext.len());
637    output.extend_from_slice(FILE_KEY_MAGIC);
638    output.push(FILE_KEY_VERSION);
639    output.extend_from_slice(&nonce);
640    output.extend_from_slice(&ciphertext);
641
642    Ok(output)
643}
644
645/// Decrypt a key from file-based storage
646#[allow(clippy::indexing_slicing)] // Bounds checked
647fn decrypt_key_from_storage(author_id: AuthorId, encrypted: &[u8]) -> Result<SigningKey> {
648    // Minimum: MAGIC(4) + VERSION(1) + NONCE(12) + KEY(32) + TAG(16) = 65
649    const MIN_SIZE: usize = 4 + 1 + 12 + 32 + 16;
650
651    if encrypted.len() < MIN_SIZE {
652        return Err(AionError::InvalidFormat {
653            reason: format!(
654                "encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
655                encrypted.len()
656            ),
657        });
658    }
659
660    if &encrypted[0..4] != FILE_KEY_MAGIC {
661        return Err(AionError::InvalidFormat {
662            reason: "invalid file key magic".to_string(),
663        });
664    }
665
666    let version = encrypted[4];
667    if version != FILE_KEY_VERSION {
668        return Err(AionError::InvalidFormat {
669            reason: format!(
670                "unsupported file key version: {version} (expected: {FILE_KEY_VERSION})"
671            ),
672        });
673    }
674
675    let nonce: [u8; 12] = encrypted[5..17]
676        .try_into()
677        .map_err(|_| AionError::InvalidFormat {
678            reason: "invalid nonce".to_string(),
679        })?;
680
681    let ciphertext = &encrypted[17..];
682
683    let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
684    let aad = author_id.as_u64().to_le_bytes();
685    let key_bytes = decrypt(&machine_key, &nonce, ciphertext, &aad)?;
686
687    SigningKey::from_bytes(&key_bytes)
688}
689
690/// Derive a machine-specific encryption key
691///
692/// This provides basic obfuscation for file-based key storage.
693/// Real security comes from filesystem permissions.
694fn derive_machine_key(salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
695    use argon2::{Algorithm, Argon2, Params, Version};
696
697    // Use a combination of fixed data as "password"
698    // This isn't true security, but provides obfuscation
699    let machine_id = get_machine_identifier();
700
701    // Use lighter Argon2 params for machine key (faster startup)
702    let params = Params::new(
703        16384,    // 16 MiB memory (lighter than password derivation)
704        2,        // 2 iterations
705        2,        // 2 parallel lanes
706        Some(32), // 32-byte output
707    )
708    .map_err(|e| AionError::InvalidPrivateKey {
709        reason: format!("Argon2 params error: {e}"),
710    })?;
711
712    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
713
714    let mut output = [0u8; 32];
715    argon2
716        .hash_password_into(machine_id.as_bytes(), salt, &mut output)
717        .map_err(|e| AionError::InvalidPrivateKey {
718            reason: format!("machine key derivation failed: {e}"),
719        })?;
720
721    Ok(output)
722}
723
724/// Get a machine-specific identifier for key derivation
725///
726/// Falls back to username if machine ID isn't available.
727fn get_machine_identifier() -> String {
728    // Try to get machine ID (Linux)
729    #[cfg(target_os = "linux")]
730    {
731        if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
732            return id.trim().to_string();
733        }
734    }
735
736    // Fallback: use username + hostname
737    let username = std::env::var("USER")
738        .or_else(|_| std::env::var("USERNAME"))
739        .unwrap_or_else(|_| "aion-user".to_string());
740
741    let hostname = hostname::get().map_or_else(
742        |_| "localhost".to_string(),
743        |h| h.to_string_lossy().to_string(),
744    );
745
746    format!("{username}@{hostname}")
747}
748
749#[cfg(test)]
750#[allow(clippy::unwrap_used)]
751#[allow(clippy::indexing_slicing)]
752mod tests {
753    use super::*;
754
755    mod password_encryption {
756        use super::*;
757
758        #[test]
759        fn should_derive_consistent_key() {
760            let salt = [1u8; SALT_SIZE];
761            let key1 = derive_key_from_password("password123", &salt).unwrap();
762            let key2 = derive_key_from_password("password123", &salt).unwrap();
763            assert_eq!(key1, key2);
764        }
765
766        #[test]
767        fn should_derive_different_keys_for_different_passwords() {
768            let salt = [1u8; SALT_SIZE];
769            let key1 = derive_key_from_password("password1", &salt).unwrap();
770            let key2 = derive_key_from_password("password2", &salt).unwrap();
771            assert_ne!(key1, key2);
772        }
773
774        #[test]
775        fn should_derive_different_keys_for_different_salts() {
776            let salt1 = [1u8; SALT_SIZE];
777            let salt2 = [2u8; SALT_SIZE];
778            let key1 = derive_key_from_password("password", &salt1).unwrap();
779            let key2 = derive_key_from_password("password", &salt2).unwrap();
780            assert_ne!(key1, key2);
781        }
782
783        #[test]
784        fn should_generate_unique_salts() {
785            let salt1 = generate_salt();
786            let salt2 = generate_salt();
787            assert_ne!(salt1, salt2);
788        }
789    }
790
791    mod export_format {
792        use super::*;
793
794        #[test]
795        fn should_have_correct_magic() {
796            assert_eq!(EXPORT_MAGIC, b"AKEY");
797        }
798
799        #[test]
800        fn should_encrypt_and_decrypt_key() {
801            let signing_key = SigningKey::generate();
802            let author_id = AuthorId::new(50001);
803            let password = "test-password-123";
804
805            // Generate salt and derive key
806            let salt = generate_salt();
807            let encryption_key = derive_key_from_password(password, &salt).unwrap();
808            let nonce = generate_nonce();
809            let aad = author_id.as_u64().to_le_bytes();
810            let ciphertext =
811                encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
812
813            // Build export format: MAGIC + VERSION + SALT + NONCE + CIPHERTEXT
814            let mut encrypted = Vec::new();
815            encrypted.extend_from_slice(EXPORT_MAGIC);
816            encrypted.push(EXPORT_VERSION);
817            encrypted.extend_from_slice(&salt);
818            encrypted.extend_from_slice(&nonce);
819            encrypted.extend_from_slice(&ciphertext);
820
821            // Extract components for decryption
822            let extracted_salt: [u8; SALT_SIZE] = encrypted[5..5 + SALT_SIZE].try_into().unwrap();
823            let nonce_start = 5 + SALT_SIZE;
824            let extracted_nonce: [u8; 12] =
825                encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
826            let decrypted_ciphertext = &encrypted[nonce_start + 12..];
827
828            // Derive same key and decrypt
829            let decryption_key = derive_key_from_password(password, &extracted_salt).unwrap();
830            let key_bytes = decrypt(
831                &decryption_key,
832                &extracted_nonce,
833                decrypted_ciphertext,
834                &aad,
835            )
836            .unwrap();
837
838            assert_eq!(key_bytes.as_slice(), signing_key.to_bytes());
839        }
840
841        #[test]
842        fn should_reject_wrong_password() {
843            let signing_key = SigningKey::generate();
844            let author_id = AuthorId::new(50001);
845
846            // Encrypt with one password
847            let salt = generate_salt();
848            let encryption_key = derive_key_from_password("correct-password", &salt).unwrap();
849            let nonce = generate_nonce();
850            let aad = author_id.as_u64().to_le_bytes();
851            let ciphertext =
852                encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
853
854            // Build export format with salt
855            let mut encrypted = Vec::new();
856            encrypted.extend_from_slice(EXPORT_MAGIC);
857            encrypted.push(EXPORT_VERSION);
858            encrypted.extend_from_slice(&salt);
859            encrypted.extend_from_slice(&nonce);
860            encrypted.extend_from_slice(&ciphertext);
861
862            // Try to decrypt with wrong password (same salt)
863            let wrong_key = derive_key_from_password("wrong-password", &salt).unwrap();
864            let nonce_start = 5 + SALT_SIZE;
865            let decrypted_ciphertext = &encrypted[nonce_start + 12..];
866            let decrypted_nonce: [u8; 12] =
867                encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
868            let result = decrypt(&wrong_key, &decrypted_nonce, decrypted_ciphertext, &aad);
869
870            assert!(result.is_err());
871        }
872
873        #[test]
874        fn should_reject_invalid_magic() {
875            let mut data = vec![0u8; 81]; // Minimum size for v2 format
876            data[0..4].copy_from_slice(b"XXXX"); // Wrong magic
877
878            let keystore = KeyStore::new();
879            let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
880            assert!(result.is_err());
881        }
882
883        #[test]
884        fn should_reject_too_small_data() {
885            let data = vec![0u8; 10]; // Too small
886
887            let keystore = KeyStore::new();
888            let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
889            assert!(result.is_err());
890        }
891
892        #[test]
893        fn should_reject_wrong_version() {
894            let mut data = vec![0u8; 81];
895            data[0..4].copy_from_slice(EXPORT_MAGIC);
896            data[4] = 99; // Wrong version
897
898            let keystore = KeyStore::new();
899            let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
900            assert!(result.is_err());
901        }
902
903        #[test]
904        fn export_format_should_have_correct_size() {
905            // MAGIC(4) + VERSION(1) + SALT(16) + NONCE(12) + KEY(32) + TAG(16) = 81
906            assert_eq!(4 + 1 + SALT_SIZE + 12 + 32 + 16, 81);
907        }
908    }
909
910    // Note: Full keyring integration tests require actual OS keyring access
911    // and are better suited for integration tests or manual testing.
912    // The tests above verify the encryption/decryption logic independently.
913}