fix(store): fail closed on encryption failure in save_email #236

Merged
KofTwentyTwo merged 1 commits from story/m6-cipher-fail-closed into dev 2026-07-30 03:00:52 +00:00
2 changed files with 31 additions and 9 deletions

No files matched your search

+12 -7
View File
@@ -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<String, CipherError> {
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, "");
+19 -2
View File
@@ -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<crate::cipher::CipherError> 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)?;