Skip to main content

symbi_runtime/
crypto.rs

1//! Cryptographic utilities for Symbiont
2//!
3//! This module provides encryption and decryption capabilities using industry-standard
4//! algorithms like AES-256-GCM for symmetric encryption and Argon2 for key derivation.
5
6use aes_gcm::{
7    aead::{Aead, AeadCore, KeyInit, OsRng},
8    Aes256Gcm, Key, Nonce,
9};
10use argon2::{
11    password_hash::{rand_core::RngCore, PasswordHasher, SaltString},
12    Argon2, Params,
13};
14use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use thiserror::Error;
18
19/// Errors that can occur during cryptographic operations
20#[derive(Debug, Error)]
21pub enum CryptoError {
22    /// Invalid key format or length
23    #[error("Invalid key: {message}")]
24    InvalidKey { message: String },
25
26    /// Encryption operation failed
27    #[error("Encryption failed: {message}")]
28    EncryptionFailed { message: String },
29
30    /// Decryption operation failed
31    #[error("Decryption failed: {message}")]
32    DecryptionFailed { message: String },
33
34    /// Key derivation failed
35    #[error("Key derivation failed: {message}")]
36    KeyDerivationFailed { message: String },
37
38    /// Invalid ciphertext format
39    #[error("Invalid ciphertext format: {message}")]
40    InvalidCiphertext { message: String },
41
42    /// Base64 encoding/decoding error
43    #[error("Base64 error: {message}")]
44    Base64Error { message: String },
45}
46
47/// Encrypted data container
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EncryptedData {
50    /// Base64-encoded ciphertext
51    pub ciphertext: String,
52    /// Base64-encoded nonce/IV
53    pub nonce: String,
54    /// Base64-encoded salt used for key derivation
55    pub salt: String,
56    /// Algorithm used for encryption
57    pub algorithm: String,
58    /// Key derivation function used
59    pub kdf: String,
60}
61
62impl fmt::Display for EncryptedData {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "EncryptedData(algorithm={})", self.algorithm)
65    }
66}
67
68// ---------------------------------------------------------------------------
69// KDF parameter versioning
70// ---------------------------------------------------------------------------
71//
72// Older Symbiont builds encrypted with Argon2id(m=19 MiB, t=2, p=1, out=32).
73// Current builds use (m=65_536 KiB=64 MiB, t=3, p=1, out=32) per OWASP's
74// 2024 recommendation for *data-at-rest* KDF, which is stronger than the
75// 19 MiB / 2-iter profile used for interactive password hashing.
76//
77// The chosen parameter set is encoded into `EncryptedData.kdf`. Decryption
78// looks up the parameters by label so blobs written under legacy params keep
79// decrypting unchanged. New encryptions always use the current (stronger)
80// parameters.
81
82/// Label stamped into `EncryptedData.kdf` for legacy 19 MiB / 2 iter blobs.
83/// Referenced from tests and documentation. Unknown kdf labels fall through
84/// to the legacy parameters, so runtime code does not read this constant
85/// directly — the match in `kdf_params_for_label` is `_ => legacy`.
86#[cfg_attr(not(test), allow(dead_code))]
87const LEGACY_KDF_ID: &str = "Argon2";
88/// Label used for current 64 MiB / 3 iter blobs.
89const CURRENT_KDF_ID: &str = "Argon2id-m65536-t3-p1";
90
91/// Build the current Argon2id parameter set. Kept as a function so tests and
92/// benchmarks can observe the exact values without digging through the
93/// encrypted blob.
94fn current_kdf_params() -> Result<Params, CryptoError> {
95    Params::new(65_536, 3, 1, Some(32)).map_err(|e| CryptoError::KeyDerivationFailed {
96        message: format!("Invalid Argon2 parameters (current): {}", e),
97    })
98}
99
100/// Look up the Argon2id parameters used to produce a blob stamped with
101/// `label`. Unknown labels map to the legacy parameters for maximum
102/// backwards-compatibility — new code paths always stamp a label we know,
103/// so an unknown value indicates a pre-existing blob from before the
104/// parameter bump.
105fn kdf_params_for_label(label: &str) -> Result<Params, CryptoError> {
106    match label {
107        CURRENT_KDF_ID => current_kdf_params(),
108        _ => Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| CryptoError::KeyDerivationFailed {
109            message: format!("Invalid Argon2 parameters (legacy): {}", e),
110        }),
111    }
112}
113
114#[cfg(test)]
115mod kdf_params_tests {
116    use super::*;
117
118    #[test]
119    fn current_params_meet_owasp_at_rest_baseline() {
120        let p = current_kdf_params().expect("params build");
121        // 64 MiB memory cost and at least 3 iterations (OWASP 2024 at-rest).
122        assert!(p.m_cost() >= 65_536, "memory too low: {}", p.m_cost());
123        assert!(p.t_cost() >= 3, "iterations too low: {}", p.t_cost());
124    }
125
126    #[test]
127    fn legacy_roundtrip_still_supported() {
128        // Legacy blobs must keep decrypting; the label lookup must not fall
129        // through to the current (stronger) params or the derived key would
130        // differ from what originally produced the ciphertext.
131        let legacy = kdf_params_for_label(LEGACY_KDF_ID).expect("legacy params build");
132        assert_eq!(legacy.m_cost(), 19 * 1024);
133        assert_eq!(legacy.t_cost(), 2);
134    }
135
136    #[test]
137    fn unknown_label_falls_through_to_legacy() {
138        let p = kdf_params_for_label("unknown-kdf-label").expect("params build");
139        assert_eq!(p.m_cost(), 19 * 1024);
140        assert_eq!(p.t_cost(), 2);
141    }
142}
143
144/// AES-256-GCM encryption/decryption utilities
145pub struct Aes256GcmCrypto;
146
147impl Default for Aes256GcmCrypto {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl Aes256GcmCrypto {
154    /// Create a new Aes256GcmCrypto instance
155    pub fn new() -> Self {
156        Self
157    }
158
159    /// Encrypt data using AES-256-GCM with a direct key (for CLI usage)
160    pub fn encrypt(&self, plaintext: &[u8], key: &str) -> Result<Vec<u8>, CryptoError> {
161        // Decode the base64 key
162        let key_bytes = BASE64.decode(key).map_err(|e| CryptoError::InvalidKey {
163            message: format!("Invalid base64 key: {}", e),
164        })?;
165
166        if key_bytes.len() != 32 {
167            return Err(CryptoError::InvalidKey {
168                message: "Key must be 32 bytes".to_string(),
169            });
170        }
171
172        let cipher_key = Key::<Aes256Gcm>::from_slice(&key_bytes);
173        let cipher = Aes256Gcm::new(cipher_key);
174
175        // Generate random nonce
176        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
177
178        // Encrypt
179        let ciphertext =
180            cipher
181                .encrypt(&nonce, plaintext)
182                .map_err(|e| CryptoError::EncryptionFailed {
183                    message: e.to_string(),
184                })?;
185
186        // Combine nonce + ciphertext
187        let mut result = Vec::with_capacity(12 + ciphertext.len());
188        result.extend_from_slice(&nonce);
189        result.extend_from_slice(&ciphertext);
190
191        Ok(result)
192    }
193
194    /// Decrypt data using AES-256-GCM with a direct key (for CLI usage)
195    pub fn decrypt(&self, encrypted_data: &[u8], key: &str) -> Result<Vec<u8>, CryptoError> {
196        if encrypted_data.len() < 12 {
197            return Err(CryptoError::InvalidCiphertext {
198                message: "Encrypted data too short".to_string(),
199            });
200        }
201
202        // Decode the base64 key
203        let key_bytes = BASE64.decode(key).map_err(|e| CryptoError::InvalidKey {
204            message: format!("Invalid base64 key: {}", e),
205        })?;
206
207        if key_bytes.len() != 32 {
208            return Err(CryptoError::InvalidKey {
209                message: "Key must be 32 bytes".to_string(),
210            });
211        }
212
213        let cipher_key = Key::<Aes256Gcm>::from_slice(&key_bytes);
214        let cipher = Aes256Gcm::new(cipher_key);
215
216        // Extract nonce and ciphertext
217        let (nonce_bytes, ciphertext) = encrypted_data.split_at(12);
218        let nonce = Nonce::from_slice(nonce_bytes);
219
220        // Decrypt
221        let plaintext =
222            cipher
223                .decrypt(nonce, ciphertext)
224                .map_err(|e| CryptoError::DecryptionFailed {
225                    message: e.to_string(),
226                })?;
227
228        Ok(plaintext)
229    }
230
231    /// Encrypt data using AES-256-GCM with Argon2 key derivation (original method)
232    pub fn encrypt_with_password(
233        plaintext: &[u8],
234        password: &str,
235    ) -> Result<EncryptedData, CryptoError> {
236        // Generate random salt
237        let mut salt = [0u8; 32];
238        OsRng.fill_bytes(&mut salt);
239        let salt_string =
240            SaltString::encode_b64(&salt).map_err(|e| CryptoError::KeyDerivationFailed {
241                message: e.to_string(),
242            })?;
243
244        // Derive key using Argon2id with current at-rest parameters. The
245        // chosen params are encoded into the `kdf` field so older ciphertexts
246        // remain decryptable after future tuning.
247        let params = current_kdf_params()?;
248        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
249        let password_hash = argon2
250            .hash_password(password.as_bytes(), &salt_string)
251            .map_err(|e| CryptoError::KeyDerivationFailed {
252                message: e.to_string(),
253            })?;
254
255        // Extract the hash bytes for the encryption key
256        let hash_binding = password_hash
257            .hash
258            .ok_or_else(|| CryptoError::KeyDerivationFailed {
259                message: "Password hash generation returned None".to_string(),
260            })?;
261        let key_bytes = hash_binding.as_bytes();
262        if key_bytes.len() < 32 {
263            return Err(CryptoError::InvalidKey {
264                message: "Derived key too short".to_string(),
265            });
266        }
267
268        let key = Key::<Aes256Gcm>::from_slice(&key_bytes[..32]);
269        let cipher = Aes256Gcm::new(key);
270
271        // Generate random nonce
272        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
273
274        // Encrypt
275        let ciphertext =
276            cipher
277                .encrypt(&nonce, plaintext)
278                .map_err(|e| CryptoError::EncryptionFailed {
279                    message: e.to_string(),
280                })?;
281
282        Ok(EncryptedData {
283            ciphertext: BASE64.encode(&ciphertext),
284            nonce: BASE64.encode(nonce),
285            salt: BASE64.encode(salt),
286            algorithm: "AES-256-GCM".to_string(),
287            kdf: CURRENT_KDF_ID.to_string(),
288        })
289    }
290
291    /// Decrypt data using AES-256-GCM with Argon2 key derivation (static method)
292    pub fn decrypt_with_password(
293        encrypted_data: &EncryptedData,
294        password: &str,
295    ) -> Result<Vec<u8>, CryptoError> {
296        // Decode base64 components
297        let ciphertext =
298            BASE64
299                .decode(&encrypted_data.ciphertext)
300                .map_err(|e| CryptoError::Base64Error {
301                    message: e.to_string(),
302                })?;
303
304        let nonce_bytes =
305            BASE64
306                .decode(&encrypted_data.nonce)
307                .map_err(|e| CryptoError::Base64Error {
308                    message: e.to_string(),
309                })?;
310
311        let salt = BASE64
312            .decode(&encrypted_data.salt)
313            .map_err(|e| CryptoError::Base64Error {
314                message: e.to_string(),
315            })?;
316
317        // Reconstruct salt string
318        let salt_string =
319            SaltString::encode_b64(&salt).map_err(|e| CryptoError::KeyDerivationFailed {
320                message: e.to_string(),
321            })?;
322
323        // Select the KDF parameters that produced this ciphertext, keyed on
324        // the stored `kdf` label. Older blobs keep decrypting under the legacy
325        // 19 MiB / 2 iter params; new blobs use the stronger 64 MiB / 3 iter
326        // defaults chosen by `current_kdf_params`.
327        let params = kdf_params_for_label(&encrypted_data.kdf)?;
328        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
329        let password_hash = argon2
330            .hash_password(password.as_bytes(), &salt_string)
331            .map_err(|e| CryptoError::KeyDerivationFailed {
332                message: e.to_string(),
333            })?;
334
335        let hash_binding = password_hash
336            .hash
337            .ok_or_else(|| CryptoError::KeyDerivationFailed {
338                message: "Password hash generation returned None".to_string(),
339            })?;
340        let key_bytes = hash_binding.as_bytes();
341        if key_bytes.len() < 32 {
342            return Err(CryptoError::InvalidKey {
343                message: "Derived key too short".to_string(),
344            });
345        }
346
347        let key = Key::<Aes256Gcm>::from_slice(&key_bytes[..32]);
348        let cipher = Aes256Gcm::new(key);
349
350        // Create nonce
351        if nonce_bytes.len() != 12 {
352            return Err(CryptoError::InvalidCiphertext {
353                message: "Invalid nonce length".to_string(),
354            });
355        }
356        let nonce = Nonce::from_slice(&nonce_bytes);
357
358        // Decrypt
359        let plaintext = cipher.decrypt(nonce, ciphertext.as_ref()).map_err(|e| {
360            CryptoError::DecryptionFailed {
361                message: e.to_string(),
362            }
363        })?;
364
365        Ok(plaintext)
366    }
367}
368
369/// Utilities for key management
370pub struct KeyUtils;
371
372impl Default for KeyUtils {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378impl KeyUtils {
379    /// Create a new KeyUtils instance
380    pub fn new() -> Self {
381        Self
382    }
383
384    /// Get or create a key, prioritizing keychain, then environment, then generating new
385    ///
386    /// # Security Warning
387    ///
388    /// This method will generate a new encryption key if none is found. This can lead to
389    /// data loss if you have existing encrypted data that was encrypted with a different key.
390    ///
391    /// Key priority order:
392    /// 1. System keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
393    /// 2. Environment variable SYMBIONT_MASTER_KEY
394    /// 3. Generate new random key (⚠️ will make old encrypted data unrecoverable)
395    ///
396    /// # Recommendations
397    ///
398    /// For production deployments:
399    /// - Use a secrets management system (HashiCorp Vault, AWS Secrets Manager)
400    /// - Set SYMBIONT_MASTER_KEY environment variable with a secure key
401    /// - Ensure proper key backup and rotation procedures
402    pub fn get_or_create_key(&self) -> Result<String, CryptoError> {
403        // Try keychain first
404        if let Ok(key) = self.get_key_from_keychain("symbiont", "secrets") {
405            tracing::debug!("Using encryption key from system keychain");
406            return Ok(key);
407        }
408
409        // Try environment variable
410        if let Ok(key) = Self::get_key_from_env("SYMBIONT_MASTER_KEY") {
411            tracing::info!("Using encryption key from SYMBIONT_MASTER_KEY environment variable");
412            return Ok(key);
413        }
414
415        // Try an on-disk key file at the configured or default location.
416        // This gives containers without a D-Bus keyring a stable place to
417        // persist a generated key without the operator having to pipe it
418        // through `SYMBIONT_MASTER_KEY` every run.
419        if let Some(path) = self.resolve_key_file_path() {
420            if let Some(key) = Self::read_key_from_file(&path)? {
421                tracing::info!(path = %path.display(), "Using encryption key from file");
422                return Ok(key);
423            }
424        }
425
426        // Refuse to auto-generate in production unless the operator has
427        // explicitly opted into an ephemeral key for this process.
428        let is_prod = crate::env::is_production().map_err(|e| CryptoError::InvalidKey {
429            message: format!("SYMBIONT_ENV parse failed: {e}"),
430        })?;
431        let allow_ephemeral = std::env::var("SYMBIONT_ALLOW_EPHEMERAL_KEY")
432            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
433            .unwrap_or(false);
434        if is_prod && !allow_ephemeral {
435            return Err(CryptoError::InvalidKey {
436                message: "No encryption key source is available and SYMBIONT_ENV=production. \
437                     Provide one of: SYMBIONT_MASTER_KEY env var, an OS keyring entry, \
438                     or SYMBIONT_MASTER_KEY_FILE pointing at a 0600 key file. \
439                     Set SYMBIONT_ALLOW_EPHEMERAL_KEY=1 only if a per-process \
440                     non-persistent key is acceptable (all data will be lost on restart)."
441                    .to_string(),
442            });
443        }
444
445        tracing::warn!(
446            "No encryption key found in keychain, env, or key file. \
447             Generating a new random key; any previously-encrypted data is \
448             now UNRECOVERABLE. Set SYMBIONT_MASTER_KEY or SYMBIONT_MASTER_KEY_FILE \
449             to persist an explicit key."
450        );
451
452        // Generate a new key
453        let new_key = self.generate_key();
454
455        // Persist the new key: first try the system keychain, then fall back
456        // to a 0600 file so containers don't silently burn the key at exit.
457        match self.store_key_in_keychain("symbiont", "secrets", &new_key) {
458            Ok(_) => {
459                tracing::info!("New encryption key stored in system keychain");
460            }
461            Err(keychain_err) => {
462                tracing::warn!(
463                    error = %keychain_err,
464                    "Keychain store failed; attempting on-disk key file fallback"
465                );
466                match self.resolve_key_file_path() {
467                    Some(path) => match Self::write_key_to_file(&path, &new_key) {
468                        Ok(()) => {
469                            tracing::warn!(
470                                path = %path.display(),
471                                "New encryption key written to 0600 file. \
472                                 Back this file up — its contents are NOT logged."
473                            );
474                        }
475                        Err(file_err) => {
476                            tracing::error!(
477                                error = %file_err,
478                                "Failed to persist generated key to disk"
479                            );
480                            if !allow_ephemeral {
481                                return Err(CryptoError::InvalidKey {
482                                    message: format!(
483                                        "Failed to persist generated key (keychain: {keychain_err}; \
484                                         file: {file_err}). Set SYMBIONT_MASTER_KEY, configure \
485                                         SYMBIONT_MASTER_KEY_FILE, or set \
486                                         SYMBIONT_ALLOW_EPHEMERAL_KEY=1 to accept a \
487                                         non-persistent in-memory key."
488                                    ),
489                                });
490                            }
491                            tracing::error!(
492                                "SYMBIONT_ALLOW_EPHEMERAL_KEY=1: proceeding with an \
493                                 in-memory-only key. All encrypted data will be \
494                                 unrecoverable after this process exits."
495                            );
496                        }
497                    },
498                    None => {
499                        if !allow_ephemeral {
500                            return Err(CryptoError::InvalidKey {
501                                message: format!(
502                                    "Keychain unavailable ({keychain_err}) and no key file \
503                                     path configured. Set SYMBIONT_MASTER_KEY_FILE or \
504                                     SYMBIONT_ALLOW_EPHEMERAL_KEY=1."
505                                ),
506                            });
507                        }
508                    }
509                }
510            }
511        }
512
513        Ok(new_key)
514    }
515
516    /// Resolve the on-disk key file path.
517    ///
518    /// Precedence:
519    ///   1. `SYMBIONT_MASTER_KEY_FILE` env var (explicit path)
520    ///   2. `$XDG_STATE_HOME/symbiont/master.key`
521    ///   3. `$HOME/.symbi/master.key`
522    ///
523    /// Returns `None` when neither `HOME` nor `XDG_STATE_HOME` is set, which
524    /// only happens in exotic sandbox configurations; in that case callers
525    /// fall back to ephemeral keys (with `SYMBIONT_ALLOW_EPHEMERAL_KEY=1`).
526    fn resolve_key_file_path(&self) -> Option<std::path::PathBuf> {
527        use std::path::PathBuf;
528        if let Ok(explicit) = std::env::var("SYMBIONT_MASTER_KEY_FILE") {
529            return Some(PathBuf::from(explicit));
530        }
531        if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
532            return Some(PathBuf::from(xdg).join("symbiont").join("master.key"));
533        }
534        if let Ok(home) = std::env::var("HOME") {
535            return Some(PathBuf::from(home).join(".symbi").join("master.key"));
536        }
537        None
538    }
539
540    /// Read a key from a 0600 file, returning `Ok(None)` if the file does not
541    /// exist (so the caller can proceed to generate one). Refuses to read
542    /// files whose Unix mode grants any permission to group or other.
543    fn read_key_from_file(path: &std::path::Path) -> Result<Option<String>, CryptoError> {
544        use std::io::Read;
545        if !path.exists() {
546            return Ok(None);
547        }
548        let meta = std::fs::metadata(path).map_err(|e| CryptoError::InvalidKey {
549            message: format!("Failed to stat {}: {e}", path.display()),
550        })?;
551        #[cfg(unix)]
552        {
553            use std::os::unix::fs::PermissionsExt;
554            let mode = meta.permissions().mode() & 0o777;
555            if mode & 0o077 != 0 {
556                return Err(CryptoError::InvalidKey {
557                    message: format!(
558                        "Key file {} has insecure mode {mode:o}; expected 0600. \
559                         Run: chmod 600 {}",
560                        path.display(),
561                        path.display()
562                    ),
563                });
564            }
565        }
566        if !meta.is_file() {
567            return Err(CryptoError::InvalidKey {
568                message: format!("Key path {} is not a regular file", path.display()),
569            });
570        }
571        let mut file = std::fs::File::open(path).map_err(|e| CryptoError::InvalidKey {
572            message: format!("Failed to open {}: {e}", path.display()),
573        })?;
574        let mut contents = String::new();
575        file.read_to_string(&mut contents)
576            .map_err(|e| CryptoError::InvalidKey {
577                message: format!("Failed to read {}: {e}", path.display()),
578            })?;
579        let trimmed = contents.trim().to_string();
580        if trimmed.is_empty() {
581            return Ok(None);
582        }
583        Ok(Some(trimmed))
584    }
585
586    /// Write a key to disk at `path` with 0600 permissions on Unix. Creates
587    /// parent directories as needed. Never logs the key itself.
588    fn write_key_to_file(path: &std::path::Path, key: &str) -> Result<(), CryptoError> {
589        use std::io::Write;
590        if let Some(parent) = path.parent() {
591            std::fs::create_dir_all(parent).map_err(|e| CryptoError::InvalidKey {
592                message: format!("Failed to create key directory {}: {e}", parent.display()),
593            })?;
594        }
595
596        // Create the file with 0600 mode atomically on Unix. On other
597        // platforms we fall back to a regular create; the caller warns that
598        // the mode is not enforced.
599        #[cfg(unix)]
600        let mut file = {
601            use std::os::unix::fs::OpenOptionsExt;
602            std::fs::OpenOptions::new()
603                .write(true)
604                .create(true)
605                .truncate(true)
606                .mode(0o600)
607                .open(path)
608                .map_err(|e| CryptoError::InvalidKey {
609                    message: format!("Failed to create key file {}: {e}", path.display()),
610                })?
611        };
612        #[cfg(not(unix))]
613        let mut file = std::fs::File::create(path).map_err(|e| CryptoError::InvalidKey {
614            message: format!("Failed to create key file {}: {e}", path.display()),
615        })?;
616
617        file.write_all(key.as_bytes())
618            .map_err(|e| CryptoError::InvalidKey {
619                message: format!("Failed to write key file {}: {e}", path.display()),
620            })?;
621        file.sync_all().map_err(|e| CryptoError::InvalidKey {
622            message: format!("Failed to fsync key file {}: {e}", path.display()),
623        })?;
624        Ok(())
625    }
626
627    /// Generate a new random key
628    pub fn generate_key(&self) -> String {
629        use base64::Engine;
630        let mut key_bytes = [0u8; 32];
631        OsRng.fill_bytes(&mut key_bytes);
632        BASE64.encode(key_bytes)
633    }
634
635    /// Store a key in the OS keychain
636    #[cfg(feature = "keychain")]
637    fn store_key_in_keychain(
638        &self,
639        service: &str,
640        account: &str,
641        key: &str,
642    ) -> Result<(), CryptoError> {
643        use keyring::Entry;
644
645        let entry = Entry::new(service, account).map_err(|e| CryptoError::InvalidKey {
646            message: format!("Failed to create keychain entry: {}", e),
647        })?;
648
649        entry
650            .set_password(key)
651            .map_err(|e| CryptoError::InvalidKey {
652                message: format!("Failed to store in keychain: {}", e),
653            })
654    }
655
656    #[cfg(not(feature = "keychain"))]
657    fn store_key_in_keychain(
658        &self,
659        _service: &str,
660        _account: &str,
661        _key: &str,
662    ) -> Result<(), CryptoError> {
663        Err(CryptoError::InvalidKey {
664            message: "Keychain support not enabled. Compile with 'keychain' feature.".to_string(),
665        })
666    }
667
668    /// Retrieve a key from environment variable
669    pub fn get_key_from_env(env_var: &str) -> Result<String, CryptoError> {
670        std::env::var(env_var).map_err(|_| CryptoError::InvalidKey {
671            message: format!("Environment variable {} not found", env_var),
672        })
673    }
674
675    /// Retrieve a key from OS keychain (cross-platform)
676    #[cfg(feature = "keychain")]
677    pub fn get_key_from_keychain(
678        &self,
679        service: &str,
680        account: &str,
681    ) -> Result<String, CryptoError> {
682        use keyring::Entry;
683
684        let entry = Entry::new(service, account).map_err(|e| CryptoError::InvalidKey {
685            message: format!("Failed to create keychain entry: {}", e),
686        })?;
687
688        entry.get_password().map_err(|e| CryptoError::InvalidKey {
689            message: format!("Failed to retrieve from keychain: {}", e),
690        })
691    }
692
693    #[cfg(not(feature = "keychain"))]
694    pub fn get_key_from_keychain(
695        &self,
696        _service: &str,
697        _account: &str,
698    ) -> Result<String, CryptoError> {
699        Err(CryptoError::InvalidKey {
700            message: "Keychain support not enabled. Compile with 'keychain' feature.".to_string(),
701        })
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_encrypt_decrypt_roundtrip() {
711        let plaintext = b"Hello, world!";
712        let password = "test1"; // Test password
713
714        let encrypted = Aes256GcmCrypto::encrypt_with_password(plaintext, password).unwrap();
715        let decrypted = Aes256GcmCrypto::decrypt_with_password(&encrypted, password).unwrap();
716
717        assert_eq!(plaintext, decrypted.as_slice());
718    }
719
720    #[test]
721    fn test_encrypt_decrypt_wrong_password() {
722        let plaintext = b"Hello, world!";
723        let password = "test1"; // Test password
724        let wrong_password = "wrong1"; // Wrong test password
725
726        let encrypted = Aes256GcmCrypto::encrypt_with_password(plaintext, password).unwrap();
727        let result = Aes256GcmCrypto::decrypt_with_password(&encrypted, wrong_password);
728
729        assert!(result.is_err());
730    }
731
732    #[test]
733    fn test_direct_encrypt_decrypt_roundtrip() {
734        let plaintext = b"Hello, world!";
735        let key_utils = KeyUtils::new();
736        let key = key_utils.generate_key();
737
738        let crypto = Aes256GcmCrypto::new();
739        let encrypted = crypto.encrypt(plaintext, &key).unwrap();
740        let decrypted = crypto.decrypt(&encrypted, &key).unwrap();
741
742        assert_eq!(plaintext, decrypted.as_slice());
743    }
744
745    #[test]
746    fn test_get_key_from_env() {
747        std::env::set_var("TEST_KEY", "test_value");
748        let result = KeyUtils::get_key_from_env("TEST_KEY").unwrap();
749        assert_eq!(result, "test_value");
750
751        let missing_result = KeyUtils::get_key_from_env("MISSING_KEY");
752        assert!(missing_result.is_err());
753    }
754}