fix(store): fail closed on encryption failure in save_email #236
No files matched your search
@@ -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, "");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
Reference in new issue
Block a user