1use 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#[derive(Debug, Error)]
21pub enum CryptoError {
22 #[error("Invalid key: {message}")]
24 InvalidKey { message: String },
25
26 #[error("Encryption failed: {message}")]
28 EncryptionFailed { message: String },
29
30 #[error("Decryption failed: {message}")]
32 DecryptionFailed { message: String },
33
34 #[error("Key derivation failed: {message}")]
36 KeyDerivationFailed { message: String },
37
38 #[error("Invalid ciphertext format: {message}")]
40 InvalidCiphertext { message: String },
41
42 #[error("Base64 error: {message}")]
44 Base64Error { message: String },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EncryptedData {
50 pub ciphertext: String,
52 pub nonce: String,
54 pub salt: String,
56 pub algorithm: String,
58 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#[cfg_attr(not(test), allow(dead_code))]
87const LEGACY_KDF_ID: &str = "Argon2";
88const CURRENT_KDF_ID: &str = "Argon2id-m65536-t3-p1";
90
91fn 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
100fn 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 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 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
144pub struct Aes256GcmCrypto;
146
147impl Default for Aes256GcmCrypto {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl Aes256GcmCrypto {
154 pub fn new() -> Self {
156 Self
157 }
158
159 pub fn encrypt(&self, plaintext: &[u8], key: &str) -> Result<Vec<u8>, CryptoError> {
161 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 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
177
178 let ciphertext =
180 cipher
181 .encrypt(&nonce, plaintext)
182 .map_err(|e| CryptoError::EncryptionFailed {
183 message: e.to_string(),
184 })?;
185
186 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 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 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 let (nonce_bytes, ciphertext) = encrypted_data.split_at(12);
218 let nonce = Nonce::from_slice(nonce_bytes);
219
220 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 pub fn encrypt_with_password(
233 plaintext: &[u8],
234 password: &str,
235 ) -> Result<EncryptedData, CryptoError> {
236 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 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 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 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
273
274 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 pub fn decrypt_with_password(
293 encrypted_data: &EncryptedData,
294 password: &str,
295 ) -> Result<Vec<u8>, CryptoError> {
296 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 let salt_string =
319 SaltString::encode_b64(&salt).map_err(|e| CryptoError::KeyDerivationFailed {
320 message: e.to_string(),
321 })?;
322
323 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 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 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
369pub struct KeyUtils;
371
372impl Default for KeyUtils {
373 fn default() -> Self {
374 Self::new()
375 }
376}
377
378impl KeyUtils {
379 pub fn new() -> Self {
381 Self
382 }
383
384 pub fn get_or_create_key(&self) -> Result<String, CryptoError> {
403 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 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 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 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 let new_key = self.generate_key();
454
455 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 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 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 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 #[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 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 #[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 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 #[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"; 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"; let wrong_password = "wrong1"; 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}