diff --git a/crates/nuncio-store/src/cipher.rs b/crates/nuncio-store/src/cipher.rs index 71c34f3..fe5a508 100644 --- a/crates/nuncio-store/src/cipher.rs +++ b/crates/nuncio-store/src/cipher.rs @@ -64,12 +64,12 @@ impl PayloadCipher { /// AES-256-GCM key. The key MUST be sourced from a [`crate::vault::SecretManager`]-backed /// vault (never a compiled-in default) so that at-rest ciphertext cannot be decrypted /// without access to the OS keyring. - pub fn encrypt_text_at_rest(key: &[u8; 32], text: &str) -> String { + pub fn encrypt_text_at_rest(key: &[u8; 32], text: &str) -> Result { if text.is_empty() { - return String::new(); + return Ok(String::new()); } - let encrypted = Self::encrypt_bytes(key, text.as_bytes()).unwrap_or_default(); - hex::encode(encrypted) + let encrypted = Self::encrypt_bytes(key, text.as_bytes())?; + Ok(hex::encode(encrypted)) } /// Decrypt text payload from database column storage at rest using a caller-supplied @@ -236,13 +236,17 @@ mod tests { fn text_at_rest_encryption_decryption_roundtrip() { let key = [7u8; 32]; let text = "Sensitive Email Body Payload"; - let encrypted = PayloadCipher::encrypt_text_at_rest(&key, text); + let encrypted = + PayloadCipher::encrypt_text_at_rest(&key, text).expect("encryption succeeds"); assert_ne!(text, encrypted); let decrypted = PayloadCipher::decrypt_text_at_rest(&key, &encrypted); assert_eq!(text, decrypted); - assert_eq!(PayloadCipher::encrypt_text_at_rest(&key, ""), ""); + assert_eq!( + PayloadCipher::encrypt_text_at_rest(&key, "").expect("empty text is not encrypted"), + "" + ); assert_eq!(PayloadCipher::decrypt_text_at_rest(&key, ""), ""); } @@ -254,7 +258,8 @@ mod tests { let key_b = [2u8; 32]; let text = "Confidential ledger payload"; - let encrypted = PayloadCipher::encrypt_text_at_rest(&key_a, text); + let encrypted = + PayloadCipher::encrypt_text_at_rest(&key_a, text).expect("encryption succeeds"); let decrypted_with_wrong_key = PayloadCipher::decrypt_text_at_rest(&key_b, &encrypted); assert_eq!(decrypted_with_wrong_key, ""); diff --git a/crates/nuncio-store/src/db.rs b/crates/nuncio-store/src/db.rs index 5f43173..7cc09b6 100644 --- a/crates/nuncio-store/src/db.rs +++ b/crates/nuncio-store/src/db.rs @@ -36,6 +36,18 @@ pub enum DatabaseError { /// compiled-in default key. #[error("cryptographic key provisioning failed: {0}")] KeyProvisioning(String), + /// A body payload failed AEAD encryption before it could be persisted at rest. + /// This is a fail-closed error: it is returned instead of ever writing an + /// empty/placeholder ciphertext that would be indistinguishable from a + /// genuinely empty body on read-back. + #[error("payload encryption failed: {0}")] + Encryption(String), +} + +impl From for DatabaseError { + fn from(err: crate::cipher::CipherError) -> Self { + DatabaseError::Encryption(err.to_string()) + } } impl DatabaseError { @@ -984,14 +996,19 @@ impl DatabaseEngine { /// is not encrypted, so this intentionally trades some body confidentiality for working /// search. pub async fn save_email(&self, email: &nuncio_core::model::Email) -> Result<(), DatabaseError> { + // Encryption failures MUST surface before any SQL runs: a swallowed error here would + // otherwise leave a row with an empty/placeholder body column, indistinguishable from + // a genuinely empty body on read-back. let enc_plain = email .body_plain .as_ref() - .map(|p| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, p)); + .map(|p| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, p)) + .transpose()?; let enc_html = email .body_html .as_ref() - .map(|h| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, h)); + .map(|h| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, h)) + .transpose()?; let mut tx = self.pool.begin().await.map_err(DatabaseError::Query)?;