From 50c3379da724812bf5953942ffd0db1d22f2eb00 Mon Sep 17 00:00:00 2001 From: ECD5A <111098024+ECD5A@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:51 +0500 Subject: [PATCH 1/2] Clear plaintext on failed tag verification --- deoxys/src/modes.rs | 8 ++++-- deoxys/tests/decrypt_failure.rs | 47 +++++++++++++++++++++++++++++++++ ocb3/src/lib.rs | 11 +++++--- ocb3/tests/kats.rs | 43 ++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 deoxys/tests/decrypt_failure.rs diff --git a/deoxys/src/modes.rs b/deoxys/src/modes.rs index b9be8bda..49c3a9c6 100644 --- a/deoxys/src/modes.rs +++ b/deoxys/src/modes.rs @@ -180,7 +180,7 @@ where fn decrypt_inout( nonce: &Array, associated_data: &[u8], - buffer: InOutBuf<'_, '_, u8>, + mut buffer: InOutBuf<'_, '_, u8>, tag: &Tag, subkeys: &Array, ) -> Result<(), aead::Error> { @@ -208,7 +208,7 @@ where tweak[0] = (tweak[0] & 0xf) | TWEAK_M; - let (data_blocks, mut tail) = buffer.into_chunks(); + let (data_blocks, mut tail) = buffer.reborrow().into_chunks(); let mut data_blocks_len = data_blocks.len(); for (index, mut data) in data_blocks.into_iter().enumerate() { @@ -271,6 +271,8 @@ where if tag.ct_eq(&computed_tag).into() { Ok(()) } else { + // Do not leave unauthenticated plaintext in the caller's buffer. + buffer.get_out().fill(0); Err(aead::Error) } } @@ -457,6 +459,8 @@ where if tag.ct_eq(&computed_tag).into() { Ok(()) } else { + // Do not leave unauthenticated plaintext in the caller's buffer. + buffer.get_out().fill(0); Err(aead::Error) } } diff --git a/deoxys/tests/decrypt_failure.rs b/deoxys/tests/decrypt_failure.rs new file mode 100644 index 00000000..b0fe06c0 --- /dev/null +++ b/deoxys/tests/decrypt_failure.rs @@ -0,0 +1,47 @@ +//! Regression tests for clearing unauthenticated plaintext. + +use deoxys::aead::{AeadInOut, Key, KeyInit, Nonce, inout::InOutBuf}; +use deoxys::{DeoxysI128, DeoxysI256, DeoxysII128, DeoxysII256}; + +fn assert_failed_decrypt_clears() +where + C: AeadInOut + KeyInit, +{ + let key = Key::::default(); + let nonce = Nonce::::default(); + let cipher = C::new(&key); + let mut ciphertext = b"plaintext that must not be exposed".to_vec(); + let mut tag = cipher + .encrypt_inout_detached(&nonce, &[], (&mut ciphertext[..]).into()) + .expect("encryption failed"); + tag[0] ^= 1; + + let mut output = vec![0xa5; ciphertext.len()]; + let buffer = InOutBuf::new(&ciphertext, &mut output).expect("input and output lengths differ"); + + cipher + .decrypt_inout_detached(&nonce, &[], buffer, &tag) + .expect_err("tampered tag was accepted"); + + assert_eq!(output, vec![0; ciphertext.len()]); +} + +#[test] +fn deoxys_i_128_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn deoxys_i_256_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn deoxys_ii_128_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn deoxys_ii_256_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} diff --git a/ocb3/src/lib.rs b/ocb3/src/lib.rs index f729546c..215dd98b 100644 --- a/ocb3/src/lib.rs +++ b/ocb3/src/lib.rs @@ -258,13 +258,15 @@ where &self, nonce: &Nonce, associated_data: &[u8], - buffer: InOutBuf<'_, '_, u8>, + mut buffer: InOutBuf<'_, '_, u8>, tag: &aead::Tag, ) -> aead::Result<()> { - let expected_tag = self.decrypt_inout_return_tag(nonce, associated_data, buffer)?; + let expected_tag = self.decrypt_inout_return_tag(nonce, associated_data, &mut buffer)?; if expected_tag.ct_eq(tag).into() { Ok(()) } else { + // Do not leave unauthenticated plaintext in the caller's buffer. + buffer.get_out().fill(0); Err(Error) } } @@ -282,7 +284,7 @@ where &self, nonce: &Nonce, associated_data: &[u8], - buffer: InOutBuf<'_, '_, u8>, + buffer: &mut InOutBuf<'_, '_, u8>, ) -> aead::Result> { let max_len = 1 << (L_TABLE_SIZE + 4); if (buffer.len() > max_len) || (associated_data.len() > max_len) { @@ -290,7 +292,8 @@ where } // First, try to process many blocks at once. - let (tail, index, mut offset_i, mut checksum_i) = self.wide_decrypt(nonce, buffer); + let (tail, index, mut offset_i, mut checksum_i) = + self.wide_decrypt(nonce, buffer.reborrow()); let mut i = index; diff --git a/ocb3/tests/kats.rs b/ocb3/tests/kats.rs index 8dee9c23..061522da 100644 --- a/ocb3/tests/kats.rs +++ b/ocb3/tests/kats.rs @@ -109,6 +109,49 @@ type Aes256Ocb3Tag64 = Ocb3; type Aes128Ocb3 = Ocb3; type Aes256Ocb3 = Ocb3; +fn assert_failed_decrypt_clears() +where + C: AeadInOut + KeyInit, +{ + let key = aead::Key::::default(); + let nonce = aead::Nonce::::default(); + let cipher = C::new(&key); + let mut ciphertext = b"plaintext that must not be exposed".to_vec(); + let mut tag = cipher + .encrypt_inout_detached(&nonce, &[], (&mut ciphertext[..]).into()) + .unwrap(); + tag[0] ^= 1; + + let mut output = vec![0xa5; ciphertext.len()]; + let buffer = aead::inout::InOutBuf::new(&ciphertext, &mut output).unwrap(); + + cipher + .decrypt_inout_detached(&nonce, &[], buffer, &tag) + .unwrap_err(); + + assert_eq!(output, vec![0; ciphertext.len()]); +} + +#[test] +fn aes128_ocb3_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn aes256_ocb3_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn aes128_ocb3_tag96_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + +#[test] +fn aes128_ocb3_tag64_failed_decrypt_clears_output() { + assert_failed_decrypt_clears::(); +} + /// Test vectors from Page 18 of https://www.rfc-editor.org/rfc/rfc7253.html#appendix-A #[test] fn rfc7253_more_sample_results() { From a39d2e95dc2b7ac753b53cecaed798912bb749d4 Mon Sep 17 00:00:00 2001 From: ECD5A <111098024+ECD5A@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:13 +0500 Subject: [PATCH 2/2] Test failed decrypts across buffer layouts and block boundaries --- deoxys/tests/decrypt_failure.rs | 60 ++++++++++++++++++++++++------- ocb3/src/lib.rs | 8 ++--- ocb3/tests/kats.rs | 64 +++++++++++++++++++++++++-------- 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/deoxys/tests/decrypt_failure.rs b/deoxys/tests/decrypt_failure.rs index b0fe06c0..72b78eed 100644 --- a/deoxys/tests/decrypt_failure.rs +++ b/deoxys/tests/decrypt_failure.rs @@ -8,22 +8,58 @@ where C: AeadInOut + KeyInit, { let key = Key::::default(); - let nonce = Nonce::::default(); let cipher = C::new(&key); - let mut ciphertext = b"plaintext that must not be exposed".to_vec(); - let mut tag = cipher - .encrypt_inout_detached(&nonce, &[], (&mut ciphertext[..]).into()) - .expect("encryption failed"); - tag[0] ^= 1; + for len in [0, 1, 15, 16, 17, 31, 32, 33, 64, 65] { + let mut nonce = Nonce::::default(); + nonce[0] = len; + let mut ciphertext = [0x42; 65]; + let ciphertext = &mut ciphertext[..usize::from(len)]; + let tag = cipher + .encrypt_inout_detached(&nonce, b"associated data", ciphertext.into()) + .expect("encryption failed"); - let mut output = vec![0xa5; ciphertext.len()]; - let buffer = InOutBuf::new(&ciphertext, &mut output).expect("input and output lengths differ"); + for tamper in 0..4 { + if tamper == 3 && ciphertext.is_empty() { + continue; + } + let mut bad_tag = tag.clone(); + let mut bad_nonce = nonce.clone(); + let mut input = ciphertext.to_vec(); + let aad: &[u8] = match tamper { + 0 => { + bad_tag[0] ^= 1; + b"associated data" + } + 1 => b"different data", + 2 => { + bad_nonce[0] ^= 1; + b"associated data" + } + _ => { + input[0] ^= 1; + b"associated data" + } + }; - cipher - .decrypt_inout_detached(&nonce, &[], buffer, &tag) - .expect_err("tampered tag was accepted"); + let mut output = vec![0xa5; input.len()]; + let buffer = InOutBuf::new(&input, &mut output).expect("equal buffer lengths"); + cipher + .decrypt_inout_detached(&bad_nonce, aad, buffer, &bad_tag) + .expect_err("tampered message was accepted"); + assert!( + output.iter().all(|&byte| byte == 0), + "len={len}, tamper={tamper}" + ); - assert_eq!(output, vec![0; ciphertext.len()]); + cipher + .decrypt_inout_detached(&bad_nonce, aad, input.as_mut_slice().into(), &bad_tag) + .expect_err("tampered message was accepted"); + assert!( + input.iter().all(|&byte| byte == 0), + "len={len}, tamper={tamper}" + ); + } + } } #[test] diff --git a/ocb3/src/lib.rs b/ocb3/src/lib.rs index 215dd98b..8abdb029 100644 --- a/ocb3/src/lib.rs +++ b/ocb3/src/lib.rs @@ -261,7 +261,8 @@ where mut buffer: InOutBuf<'_, '_, u8>, tag: &aead::Tag, ) -> aead::Result<()> { - let expected_tag = self.decrypt_inout_return_tag(nonce, associated_data, &mut buffer)?; + let expected_tag = + self.decrypt_inout_return_tag(nonce, associated_data, buffer.reborrow())?; if expected_tag.ct_eq(tag).into() { Ok(()) } else { @@ -284,7 +285,7 @@ where &self, nonce: &Nonce, associated_data: &[u8], - buffer: &mut InOutBuf<'_, '_, u8>, + buffer: InOutBuf<'_, '_, u8>, ) -> aead::Result> { let max_len = 1 << (L_TABLE_SIZE + 4); if (buffer.len() > max_len) || (associated_data.len() > max_len) { @@ -292,8 +293,7 @@ where } // First, try to process many blocks at once. - let (tail, index, mut offset_i, mut checksum_i) = - self.wide_decrypt(nonce, buffer.reborrow()); + let (tail, index, mut offset_i, mut checksum_i) = self.wide_decrypt(nonce, buffer); let mut i = index; diff --git a/ocb3/tests/kats.rs b/ocb3/tests/kats.rs index 061522da..ae137050 100644 --- a/ocb3/tests/kats.rs +++ b/ocb3/tests/kats.rs @@ -114,22 +114,58 @@ where C: AeadInOut + KeyInit, { let key = aead::Key::::default(); - let nonce = aead::Nonce::::default(); let cipher = C::new(&key); - let mut ciphertext = b"plaintext that must not be exposed".to_vec(); - let mut tag = cipher - .encrypt_inout_detached(&nonce, &[], (&mut ciphertext[..]).into()) - .unwrap(); - tag[0] ^= 1; - - let mut output = vec![0xa5; ciphertext.len()]; - let buffer = aead::inout::InOutBuf::new(&ciphertext, &mut output).unwrap(); - - cipher - .decrypt_inout_detached(&nonce, &[], buffer, &tag) - .unwrap_err(); + for len in [0, 1, 15, 16, 17, 31, 32, 33, 64, 65] { + let mut nonce = aead::Nonce::::default(); + nonce[0] = len; + let mut ciphertext = [0x42; 65]; + let ciphertext = &mut ciphertext[..usize::from(len)]; + let tag = cipher + .encrypt_inout_detached(&nonce, b"associated data", ciphertext.into()) + .unwrap(); - assert_eq!(output, vec![0; ciphertext.len()]); + for tamper in 0..4 { + if tamper == 3 && ciphertext.is_empty() { + continue; + } + let mut bad_tag = tag.clone(); + let mut bad_nonce = nonce.clone(); + let mut input = ciphertext.to_vec(); + let aad: &[u8] = match tamper { + 0 => { + bad_tag[0] ^= 1; + b"associated data" + } + 1 => b"different data", + 2 => { + bad_nonce[0] ^= 1; + b"associated data" + } + _ => { + input[0] ^= 1; + b"associated data" + } + }; + + let mut output = vec![0xa5; input.len()]; + let buffer = aead::inout::InOutBuf::new(&input, &mut output).unwrap(); + cipher + .decrypt_inout_detached(&bad_nonce, aad, buffer, &bad_tag) + .unwrap_err(); + assert!( + output.iter().all(|&byte| byte == 0), + "len={len}, tamper={tamper}" + ); + + cipher + .decrypt_inout_detached(&bad_nonce, aad, input.as_mut_slice().into(), &bad_tag) + .unwrap_err(); + assert!( + input.iter().all(|&byte| byte == 0), + "len={len}, tamper={tamper}" + ); + } + } } #[test]