//! Envelope encryption: ChaCha20-Poly1305 under a master key. Air-gapped //! installs use a generated key file (backed up by the operator); a KMS //! key source slots in behind the same seal/open surface for cloud. use std::path::Path; use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use crate::BrokerError; /// A sealed (encrypted + authenticated) value. #[derive(Debug, Clone)] pub struct Sealed { pub ciphertext: Vec, pub nonce: Vec, } /// Master key loaded from a hex-encoded 32-byte file, mode 0600. pub struct FileKey { cipher: ChaCha20Poly1305, } impl FileKey { /// Generates a fresh key file (done once by `clawmates-admin init`). pub fn generate(path: &Path) -> Result<(), BrokerError> { let key = ChaCha20Poly1305::generate_key(&mut OsRng); std::fs::write(path, hex::encode(key)).map_err(|e| BrokerError::Io(e.to_string()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) .map_err(|e| BrokerError::Io(e.to_string()))?; } Ok(()) } pub fn load(path: &Path) -> Result { let encoded = std::fs::read_to_string(path).map_err(|e| BrokerError::Io(e.to_string()))?; let bytes = hex::decode(encoded.trim()) .map_err(|e| BrokerError::Crypto(format!("key file is not hex: {e}")))?; if bytes.len() != 32 { return Err(BrokerError::Crypto(format!( "key must be 32 bytes, found {}", bytes.len() ))); } Ok(FileKey { cipher: ChaCha20Poly1305::new(Key::from_slice(&bytes)), }) } pub fn seal(&self, plaintext: &[u8]) -> Result { let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let ciphertext = self .cipher .encrypt(&nonce, plaintext) .map_err(|e| BrokerError::Crypto(e.to_string()))?; Ok(Sealed { ciphertext, nonce: nonce.to_vec(), }) } pub fn open(&self, sealed: &Sealed) -> Result, BrokerError> { if sealed.nonce.len() != 12 { return Err(BrokerError::Crypto("nonce must be 12 bytes".into())); } self.cipher .decrypt(Nonce::from_slice(&sealed.nonce), sealed.ciphertext.as_ref()) .map_err(|_| BrokerError::Crypto("decryption failed (tampered or wrong key)".into())) } }