From b5fcf99252c22b3222bc51bacdb3e7e751516fed Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 9 Sep 2026 23:58:37 +0700 Subject: [PATCH 01/16] core, core-test-framework: AEADCipherEncryptor/AEADCipherDecryptor gain update_out_len and a FINAL_LEN final buffer so a buffering cipher or an inline ciphertext||tag layout can be expressed; TaggedEncryptor/TaggedDecryptor adapt any FINAL_LEN=0 pair to the SimpleCipherEncryptor/SimpleCipherDecryptor ciphertext||tag shape; the block, simple-cipher and AEAD strength sweeps assert they are not vacuous, and the AEAD streaming suite gains a genuinely-buffering toy plus undersized-buffer and std-one-shot coverage --- .../src/symmetric_ciphers.rs | 610 +++++++++++++++++- crypto/core/src/lib.rs | 1 + crypto/core/src/tagged_aead.rs | 529 +++++++++++++++ crypto/core/src/traits.rs | 388 ++++++++++- 4 files changed, 1514 insertions(+), 14 deletions(-) create mode 100644 crypto/core/src/tagged_aead.rs diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index b3878ac7..3809fc55 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,8 +6,9 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, - SimpleCipherDecryptor, SimpleCipherEncryptor, StreamCipherDecryptor, StreamCipherEncryptor, + AEADCipher, AEADCipherDecryptor, AEADCipherEncryptor, BlockCipherDecryptor, + BlockCipherEncryptor, SecurityStrength, SimpleCipherDecryptor, SimpleCipherEncryptor, + StreamCipherDecryptor, StreamCipherEncryptor, }; /// Instance of the test framework. @@ -408,6 +409,7 @@ impl TestFrameworkBlockCipher { SecurityStrength::_192bit, SecurityStrength::_256bit, ]; + let mut strengths_tested = 0; for ss in security_strengths.iter() { // `set_security_strength` enforces its key-length guard even inside a // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a @@ -418,9 +420,10 @@ impl TestFrameworkBlockCipher { if ss > &SecurityStrength::from_bytes(KEY_LEN) { continue; } - - // Tag the key at an arbitrary strength for the purpose of this test. + // Inside a do_hazardous_operations() closure set_security_strength() raises the + // strength without complaining; any error here is a framework bug, hence unwrap(). do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); + strengths_tested += 1; match E::do_encrypt_init(&key) { Ok(_) => { @@ -438,6 +441,7 @@ impl TestFrameworkBlockCipher { _ => panic!("Unexpected error"), }; } + assert!(strengths_tested > 0, "strength sweep must not be vacuous"); } } @@ -595,15 +599,21 @@ impl TestFrameworkAEADCipher { // Modifying the ciphertext MUST cause an AEAD failure: unlike an unauthenticated cipher, // a conformant AEAD must never return plaintext for a ciphertext that fails its tag check. ct[17] ^= 0xFF; + pt[..ct_bytes_written].fill(0xAA); match C::aead_decrypt_out(&key, &nonce, aad, &ct[..ct_bytes_written], &tag, &mut pt) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } Err(SymmetricCipherError::DecryptionFailed) => { /* also acceptable */ } _ => panic!("Modified ciphertext must fail the AEAD tag check"), }; + assert!( + pt[..ct_bytes_written].iter().all(|&b| b == 0), + "AEAD must not leave plaintext in the output buffer after a failed tag check" + ); // restore the ciphertext so the AAD- and tag-tamper checks below each test one variable ct[17] ^= 0xFF; // messing with the aad causes the aead_decrypt to fail + pt[..ct_bytes_written].fill(0xAA); match C::aead_decrypt_out( &key, &nonce, @@ -615,8 +625,13 @@ impl TestFrameworkAEADCipher { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } _ => panic!("Expected TagCheckFailed error"), }; + assert!( + pt[..ct_bytes_written].iter().all(|&b| b == 0), + "AEAD must not leave plaintext in the output buffer after a failed tag check" + ); // messing with the tag causes the aead_decrypt to fail + pt[..ct_bytes_written].fill(0xAA); match C::aead_decrypt_out( &key, &nonce, @@ -628,6 +643,10 @@ impl TestFrameworkAEADCipher { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } _ => panic!("Expected TagCheckFailed error"), }; + assert!( + pt[..ct_bytes_written].iter().all(|&b| b == 0), + "AEAD must not leave plaintext in the output buffer after a failed tag check" + ); // multiple invocations give different nonces let (nonce1, _ct_bytes_written, _tag) = @@ -658,6 +677,7 @@ impl TestFrameworkAEADCipher { SecurityStrength::_192bit, SecurityStrength::_256bit, ]; + let mut strengths_tested = 0; for ss in security_strengths.iter() { // `set_security_strength` enforces its key-length guard even inside a // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a @@ -671,6 +691,7 @@ impl TestFrameworkAEADCipher { // Tag the key at an arbitrary strength for the purpose of this test. do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); + strengths_tested += 1; // The key-strength requirement must be enforced both by the AEAD one-shot and by the // plain one (encrypt_out), so exercise both. @@ -692,6 +713,587 @@ impl TestFrameworkAEADCipher { check_strength(C::aead_encrypt_out(&key, aad, msg, &mut ct).map(|_| ())); check_strength(C::encrypt_out(&key, msg, &mut ct).map(|_| ())); } + assert!(strengths_tested > 0, "strength sweep must not be vacuous"); + } + + /// Exercises the [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] streaming contract for a + /// paired implementor. The counterpart of [`TestFrameworkBlockCipher::test`] for an + /// authenticated cipher. + /// + /// Checks, in order: + /// * the one-shot round trip for every message length from 0 to a few times `TAG_LEN`, and + /// that the tag is not the all-zero array; + /// * streaming in every chunking, of both the AAD and the data, agrees with `update_out_len` + /// on every call and gives the one-shot's ciphertext and tag byte for byte, and decrypts in + /// every chunking; + /// * an empty AAD is a no-op -- it gives what absorbing no AAD at all gives -- and a message + /// with no data still authenticates its AAD; + /// * `do_update_aad` with non-empty AAD after the first `do_update_out` is refused with a + /// [`SymmetricCipherError::StateError`], and the refusal leaves the value usable; + /// * a tampered ciphertext, tag, AAD or nonce all fail the tag check, and the one-shot + /// `decrypt` leaves no plaintext behind when they do; + /// * two encryptions under the same key draw different nonces; + /// * a key of the wrong [`KeyType`] is rejected, and the security-strength policy matches + /// [`Algorithm::MAX_SECURITY_STRENGTH`]. + /// + /// This only ever drives `E`/`D` with `FINAL_LEN` bytes-or-fewer actually flushed at + /// finalization; it does not by itself prove that a *genuinely buffering* implementor's + /// `update_out_len` is honoured mid-stream (nothing here ever expects `do_update_out` to + /// return less than it was given). [`Self::test_buffering_toy`] pins that separately, against + /// a toy built to hold data back, since `E`/`D` here are supplied by the caller and might not + /// exercise it. + /// + /// [`Algorithm::MAX_SECURITY_STRENGTH`]: bouncycastle_core::traits::Algorithm::MAX_SECURITY_STRENGTH + pub fn test_encryptor_decryptor< + const KEY_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const FINAL_LEN: usize, + E: AEADCipherEncryptor, + D: AEADCipherDecryptor, + >( + &self, + ) { + let key = KeyMaterial::::from_bytes_as_type( + &DUMMY_SEED[..KEY_LEN], + KeyType::SymmetricCipherKey, + ) + .unwrap(); + let aad: &[u8] = b"some associated data"; + + // one-shot round trip, every length up to a few times the tag length + let max_len = 3 * TAG_LEN.max(1) + 5; + for len in 0..=max_len { + let msg = &DUMMY_SEED[..len]; + let mut ct = vec![0u8; E::encrypt_out_len(len)]; + let (nonce, ct_len, tag) = E::encrypt_out(&key, aad, msg, &mut ct).unwrap(); + ct.truncate(ct_len); + assert_ne!(tag, [0u8; TAG_LEN], "len {len}: the tag must not be all zeros"); + // Only assert the ciphertext differs from the plaintext once there is enough of it for + // an accidental match to be negligible rather than a 1-in-256 flake. + if len >= 8 { + assert_ne!(&ct[..], msg, "len {len}: the ciphertext must not be the plaintext"); + } + let mut pt = vec![0u8; D::decrypt_out_max_len(ct.len())]; + let pt_len = D::decrypt_out(&key, &nonce, aad, &ct, &tag, &mut pt).unwrap(); + pt.truncate(pt_len); + assert_eq!(&pt[..], msg, "one-shot round trip, len {len}"); + + // the std one-shots agree with the _out ones for the same nonce + let (nonce2, ct2, tag2) = E::encrypt(&key, aad, msg).unwrap(); + assert_eq!(ct2.len(), ct_len, "encrypt must return exactly the bytes written"); + let pt2 = D::decrypt(&key, &nonce2, aad, &ct2, &tag2).unwrap(); + assert_eq!(pt2, msg, "std round trip, len {len}"); + let pt3 = D::decrypt(&key, &nonce, aad, &ct, &tag).unwrap(); + assert_eq!(pt3, msg, "decrypt must agree with decrypt_out"); + + // too-short output buffers on the one-shots are refused with the required length, + // before any work is done + let need = E::encrypt_out_len(len); + if need > 0 { + let mut short = vec![0u8; need - 1]; + match E::encrypt_out(&key, aad, msg, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { + assert_eq!(n, need) + } + other => panic!("encrypt_out into a short buffer: {other:?}"), + } + let mut short = vec![0u8; need - 1]; + match E::encrypt_out_rng( + &key, + &mut FixedSeedRNG::::new([0xA5u8; NONCE_LEN]), + aad, + msg, + &mut short, + ) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { + assert_eq!(n, need) + } + other => panic!("encrypt_out_rng into a short buffer: {other:?}"), + } + } + let need = D::decrypt_out_max_len(ct.len()); + if need > 0 { + let mut short = vec![0u8; need - 1]; + match D::decrypt_out(&key, &nonce, aad, &ct, &tag, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { + assert_eq!(n, need) + } + other => panic!("decrypt_out into a short buffer: {other:?}"), + } + } + } + + // streaming in every chunking agrees with the one-shot, for both the AAD and the data. + // The pinned RNG is what makes the nonce -- and so the ciphertext -- comparable. + let msg = &DUMMY_SEED[..max_len.max(17)]; + let pinned = [0xA5u8; NONCE_LEN]; + let mut ct_ref = vec![0u8; E::encrypt_out_len(msg.len())]; + let (nonce_ref, ct_ref_len, tag_ref) = E::encrypt_out_rng( + &key, + &mut FixedSeedRNG::::new(pinned), + aad, + msg, + &mut ct_ref, + ) + .unwrap(); + ct_ref.truncate(ct_ref_len); + + for chunk in [1usize, 2, 3, 7, TAG_LEN.max(1), TAG_LEN + 1, msg.len()] { + let (mut enc, nonce) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(pinned)).unwrap(); + assert_eq!(nonce, nonce_ref, "the same RNG stream must give the same nonce"); + for piece in aad.chunks(chunk) { + enc.do_update_aad(piece).unwrap(); + } + let mut ct = Vec::new(); + for piece in msg.chunks(chunk) { + let expect = enc.update_out_len(piece.len()); + let mut buf = vec![0u8; expect]; + let n = enc.do_update_out(piece, &mut buf).unwrap(); + assert_eq!(n, expect, "chunk {chunk}: update_out_len must be exact (encrypt)"); + ct.extend_from_slice(&buf[..n]); + } + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + ct.extend_from_slice(&final_buf[..final_len]); + assert_eq!(ct, ct_ref, "chunk {chunk}: streaming must give the one-shot ciphertext"); + assert_eq!(tag, tag_ref, "chunk {chunk}: streaming must give the one-shot tag"); + + // ...and the decryptor agrees in every chunking too + let mut dec = D::do_decrypt_init(&key, &nonce).unwrap(); + for piece in aad.chunks(chunk) { + dec.do_update_aad(piece).unwrap(); + } + let mut pt = Vec::new(); + for piece in ct.chunks(chunk) { + let expect = dec.update_out_len(piece.len()); + let mut buf = vec![0u8; expect]; + let n = dec.do_update_out(piece, &mut buf).unwrap(); + assert_eq!(n, expect, "chunk {chunk}: update_out_len must be exact (decrypt)"); + pt.extend_from_slice(&buf[..n]); + } + let mut final_buf = [0u8; FINAL_LEN]; + let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + pt.extend_from_slice(&final_buf[..final_len]); + assert_eq!(pt, msg, "chunk {chunk}: streaming round trip"); + } + + // too-short output buffers on the streaming `do_update_out` are refused with the required + // length, before any work is done -- on both sides, not just the one-shots above. + if !msg.is_empty() { + let (mut enc, _) = E::do_encrypt_init(&key).unwrap(); + let need = enc.update_out_len(msg.len()); + if need > 0 { + let mut short = vec![0u8; need - 1]; + match enc.do_update_out(msg, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { + assert_eq!(n, need) + } + other => panic!("encrypt do_update_out into a short buffer: {other:?}"), + } + } + + let (mut dec, _) = { + let (mut enc, nonce) = E::do_encrypt_init(&key).unwrap(); + let mut ct = vec![0u8; enc.update_out_len(msg.len())]; + enc.do_update_out(msg, &mut ct).unwrap(); + (D::do_decrypt_init(&key, &nonce).unwrap(), ct) + }; + let need = dec.update_out_len(msg.len()); + if need > 0 { + let mut short = vec![0u8; need - 1]; + match dec.do_update_out(msg, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { + assert_eq!(n, need) + } + other => panic!("decrypt do_update_out into a short buffer: {other:?}"), + } + } + } + + // an empty AAD is a no-op: it must give exactly what absorbing no AAD at all gives + let mut with_empty = vec![0u8; E::encrypt_out_len(msg.len())]; + let (nonce_empty, len_empty, tag_empty) = E::encrypt_out_rng( + &key, + &mut FixedSeedRNG::::new(pinned), + b"", + msg, + &mut with_empty, + ) + .unwrap(); + with_empty.truncate(len_empty); + let mut without = vec![0u8; E::encrypt_out_len(msg.len())]; + let (nonce_none, len_none, tag_none) = E::encrypt_out_rng( + &key, + &mut FixedSeedRNG::::new(pinned), + &[], + msg, + &mut without, + ) + .unwrap(); + without.truncate(len_none); + assert_eq!(nonce_empty, nonce_none); + assert_eq!(tag_empty, tag_none, "an empty AAD must be a no-op"); + assert_eq!(with_empty, without, "an empty AAD must be a no-op"); + + // a message with no data at all still authenticates its AAD + let (nonce, _ct_len, tag) = E::encrypt_out(&key, aad, &[], &mut []).unwrap(); + D::decrypt_out(&key, &nonce, aad, &[], &tag, &mut []).unwrap(); + match D::decrypt_out(&key, &nonce, b"different associated data", &[], &tag, &mut []) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("an empty message must still authenticate its AAD, got {other:?}"), + }; + + // the AAD phase is over once data has been fed in -- on both sides + let (mut enc, nonce) = E::do_encrypt_init(&key).unwrap(); + let mut ct = vec![0u8; enc.update_out_len(msg.len())]; + enc.do_update_out(msg, &mut ct).unwrap(); + match enc.do_update_aad(aad) { + Err(SymmetricCipherError::StateError(_)) => { /* good */ } + other => panic!("AAD after data must be refused, got {other:?}"), + }; + // an empty AAD stays a no-op even here, and the refused call must not have disturbed the + // state: the value is still good for the rest of the flow. + enc.do_update_aad(b"").unwrap(); + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + ct.extend_from_slice(&final_buf[..final_len]); + + let mut dec = D::do_decrypt_init(&key, &nonce).unwrap(); + let mut pt = vec![0u8; dec.update_out_len(ct.len())]; + dec.do_update_out(&ct, &mut pt).unwrap(); + match dec.do_update_aad(aad) { + Err(SymmetricCipherError::StateError(_)) => { /* good */ } + other => panic!("AAD after data must be refused, got {other:?}"), + }; + dec.do_update_aad(b"").unwrap(); + let mut final_buf = [0u8; FINAL_LEN]; + let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + pt.extend_from_slice(&final_buf[..final_len]); + assert_eq!(&pt[..], msg, "a refused do_update_aad must not disturb the state"); + + // tampering: every one of these must fail the tag check, and the one-shot must leave no + // plaintext behind when it does + let mut ct = vec![0u8; E::encrypt_out_len(msg.len())]; + let (nonce, ct_len, tag) = E::encrypt_out(&key, aad, msg, &mut ct).unwrap(); + ct.truncate(ct_len); + + let mut tampered = ct.clone(); + tampered[3] ^= 0xFF; + let mut buf = vec![0u8; D::decrypt_out_max_len(tampered.len())]; + match D::decrypt_out(&key, &nonce, aad, &tampered, &tag, &mut buf) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("a modified ciphertext must fail the tag check, got {other:?}"), + }; + assert!( + buf.iter().all(|&b| b == 0), + "the one-shot decrypt must zeroize the buffer when the tag check fails" + ); + + let mut wrong_tag = tag; + wrong_tag[0] ^= 0xFF; + let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; + match D::decrypt_out(&key, &nonce, aad, &ct, &wrong_tag, &mut buf) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("a modified tag must fail the tag check, got {other:?}"), + }; + + let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; + match D::decrypt_out(&key, &nonce, b"not the right associated data", &ct, &tag, &mut buf) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("a modified AAD must fail the tag check, got {other:?}"), + }; + + if NONCE_LEN > 0 { + let mut wrong_nonce = nonce; + wrong_nonce[0] ^= 0xFF; + let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; + match D::decrypt_out(&key, &wrong_nonce, aad, &ct, &tag, &mut buf) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("a modified nonce must fail the tag check, got {other:?}"), + }; + + // two encryptions under the same key must not reuse a nonce + let (_enc1, nonce1) = E::do_encrypt_init(&key).unwrap(); + let (_enc2, nonce2) = E::do_encrypt_init(&key).unwrap(); + assert_ne!(nonce1, nonce2); + } + + // error case: KeyMaterial of wrong type + let mac_key = + KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) + .unwrap(); + match E::do_encrypt_init(&mac_key) { + Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } + _ => panic!("Unexpected error"), + }; + match D::do_decrypt_init(&mac_key, &nonce) { + Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } + _ => panic!("Unexpected error"), + }; + + // error case: security strengths too weak and too strong + let mut key = KeyMaterial::::from_bytes_as_type( + &DUMMY_SEED[..KEY_LEN], + KeyType::SymmetricCipherKey, + ) + .unwrap(); + let security_strengths = [ + SecurityStrength::None, + SecurityStrength::_112bit, + SecurityStrength::_128bit, + SecurityStrength::_192bit, + SecurityStrength::_256bit, + ]; + let mut strengths_tested = 0; + for ss in security_strengths.iter() { + // See the note in `test_plain_one_shots`: a KEY_LEN-byte key cannot be tagged above + // `from_bytes(KEY_LEN)` even inside `do_hazardous_operations`, so skip the strengths + // this key cannot carry. + if ss > &SecurityStrength::from_bytes(KEY_LEN) { + continue; + } + + // Tag the key at an arbitrary strength for the purpose of this test. + do_hazardous_operations(&mut key, |key| key.set_security_strength(*ss)).unwrap(); + strengths_tested += 1; + + // Both directions must enforce the same policy. + let check_strength = |result: Result<(), SymmetricCipherError>| match result { + Ok(_) => { + if ss >= &E::MAX_SECURITY_STRENGTH { /* good */ + } else { + panic!("Should have been a strong enough key"); + } + } + Err(SymmetricCipherError::KeyMaterialError(_)) => { + if ss < &E::MAX_SECURITY_STRENGTH { /* good */ + } else { + panic!("Should not have accepted a key weaker than algorithm"); + } + } + _ => panic!("Unexpected error"), + }; + check_strength(E::do_encrypt_init(&key).map(|_| ())); + check_strength(D::do_decrypt_init(&key, &nonce).map(|_| ())); + } + assert!(strengths_tested > 0, "strength sweep must not be vacuous"); + } + + /// Pins that a *genuinely buffering* [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] pair's + /// `update_out_len` is honoured through every chunking, against a toy built to hold back up to + /// three bytes at a time before releasing them -- the property + /// [`Self::test_encryptor_decryptor`] cannot pin on its own, since a caller-supplied `E`/`D` + /// might never buffer (Ascon-AEAD128 never does). Modelled on the toy permutations + /// `crypto/modes/tests/common/mod.rs` uses for the equivalent block-cipher property. + /// + /// The toy's "ciphertext" is the plaintext with a per-byte counter XORed in, released three + /// bytes behind what it has consumed (so `update_out_len(n)` is `0` for the first two bytes of + /// any run and `n` thereafter, once three bytes are already buffered); its "tag" is a length + /// check. Not remotely a real AEAD -- it exists solely to make holding data back observable. + pub fn test_buffering_toy(&self) { + use bouncycastle_core::errors::SymmetricCipherError; + use bouncycastle_core::key_material::{KeyMaterial, KeyType}; + use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, + }; + + const HOLD_BACK: usize = 3; + const KEY_LEN: usize = 4; + const NONCE_LEN: usize = 4; + const TAG_LEN: usize = 1; + + struct Buffered { + pos: u8, + held: [u8; HOLD_BACK], + held_len: usize, + len_seen: usize, + } + + impl Buffered { + fn new() -> Self { + Self { pos: 0, held: [0u8; HOLD_BACK], held_len: 0, len_seen: 0 } + } + + /// Feeds `input` in, holding back the last `HOLD_BACK` bytes and releasing (XORed + /// with a running counter) everything older than that into `output`. + fn update_out(&mut self, input: &[u8], output: &mut [u8]) -> usize { + self.len_seen += input.len(); + let total = self.held_len + input.len(); + let releasable = total.saturating_sub(HOLD_BACK); + let from_held = self.held_len.min(releasable); + let from_new = releasable - from_held; + for (i, b) in self.held[..from_held].iter().enumerate() { + output[i] = *b ^ self.pos; + self.pos = self.pos.wrapping_add(1); + } + for (i, b) in input[..from_new].iter().enumerate() { + output[from_held + i] = *b ^ self.pos; + self.pos = self.pos.wrapping_add(1); + } + // The amount kept is `total - releasable`, which is `HOLD_BACK` once `total` + // reaches it but only `total` itself before that -- so the tail of `new_held` + // actually in use is `new_len`, not always the full array up to `HOLD_BACK`. + let new_len = total - releasable; + let mut new_held = [0u8; HOLD_BACK]; + let kept_from_held = self.held_len - from_held; + new_held[..kept_from_held].copy_from_slice(&self.held[from_held..self.held_len]); + new_held[kept_from_held..new_len].copy_from_slice(&input[from_new..]); + self.held = new_held; + self.held_len = new_len; + releasable + } + + fn finish(self, output: &mut [u8]) -> usize { + for (i, b) in self.held[..self.held_len].iter().enumerate() { + output[i] = *b ^ self.pos; + } + self.held_len + } + } + + struct Enc(Buffered); + struct Dec(Buffered); + + impl Algorithm for Enc { + const ALG_NAME: &'static str = "buffering-toy"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; + } + impl Algorithm for Dec { + const ALG_NAME: &'static str = "buffering-toy"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; + } + + impl AEADCipherEncryptor for Enc { + fn do_encrypt_init( + _key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Ok((Self(Buffered::new()), [0u8; NONCE_LEN])) + } + fn do_encrypt_init_rng( + key: &KeyMaterial, + _rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Self::do_encrypt_init(key) + } + fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + (self.0.held_len + input_len).saturating_sub(HOLD_BACK) + } + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + Ok(self.0.update_out(plaintext, ciphertext)) + } + fn do_encrypt_final( + self, + output: &mut [u8; HOLD_BACK], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + let len_seen = self.0.len_seen; + let n = self.0.finish(output); + Ok((n, [(len_seen % 256) as u8; TAG_LEN])) + } + } + + impl AEADCipherDecryptor for Dec { + fn do_decrypt_init( + _key: &KeyMaterial, + _nonce: &[u8; NONCE_LEN], + ) -> Result { + Ok(Self(Buffered::new())) + } + fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + (self.0.held_len + input_len).saturating_sub(HOLD_BACK) + } + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + Ok(self.0.update_out(ciphertext, plaintext)) + } + fn do_decrypt_final( + self, + tag: &[u8; TAG_LEN], + output: &mut [u8; HOLD_BACK], + ) -> Result { + let len_seen = self.0.len_seen; + let n = self.0.finish(output); + if *tag != [(len_seen % 256) as u8; TAG_LEN] { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok(n) + } + } + + let key = KeyMaterial::::from_bytes_as_type( + &DUMMY_SEED[..KEY_LEN], + KeyType::SymmetricCipherKey, + ) + .unwrap(); + + for len in 0..=(3 * HOLD_BACK + 5) { + let msg = &DUMMY_SEED[..len]; + let mut ct = vec![0u8; len + HOLD_BACK]; + let (nonce, ct_len, tag) = Enc::encrypt_out(&key, b"", msg, &mut ct).unwrap(); + ct.truncate(ct_len); + assert_eq!(ct_len, len, "the toy never expands the data, only the finalizer flushes"); + + for chunk in [1usize, 2, 3, HOLD_BACK, HOLD_BACK + 1, len.max(1)] { + let (mut enc, _) = Enc::do_encrypt_init(&key).unwrap(); + let mut chunked = Vec::new(); + for piece in msg.chunks(chunk) { + let expect = enc.update_out_len(piece.len()); + let mut buf = vec![0u8; expect]; + let n = enc.do_update_out(piece, &mut buf).unwrap(); + assert_eq!(n, expect, "len {len} chunk {chunk}: update_out_len must be exact"); + chunked.extend_from_slice(&buf[..n]); + } + let mut final_buf = [0u8; HOLD_BACK]; + let (final_len, chunked_tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + chunked.extend_from_slice(&final_buf[..final_len]); + assert_eq!(chunked, ct, "len {len} chunk {chunk}: chunking must not be visible"); + assert_eq!( + chunked_tag, tag, + "len {len} chunk {chunk}: tag must not depend on chunking" + ); + + let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); + let mut pt = Vec::new(); + for piece in ct.chunks(chunk) { + let expect = dec.update_out_len(piece.len()); + let mut buf = vec![0u8; expect]; + let n = dec.do_update_out(piece, &mut buf).unwrap(); + assert_eq!(n, expect, "len {len} chunk {chunk}: update_out_len must be exact"); + pt.extend_from_slice(&buf[..n]); + } + let mut final_buf = [0u8; HOLD_BACK]; + let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + pt.extend_from_slice(&final_buf[..final_len]); + assert_eq!(pt, msg, "len {len} chunk {chunk}: round trip"); + } + + // For any length past the hold-back window, at least one prefix of the input must be + // held back rather than released immediately -- the property this whole test exists + // to pin. (For `len < HOLD_BACK` nothing is ever releasable until `do_encrypt_final`, + // which is also correct but does not exercise `do_update_out` returning less than it + // was given.) + if len > HOLD_BACK { + let (mut enc, _) = Enc::do_encrypt_init(&key).unwrap(); + let first = &msg[..1]; + let mut buf = vec![0u8; enc.update_out_len(first.len())]; + let n = enc.do_update_out(first, &mut buf).unwrap(); + assert_eq!(n, 0, "len {len}: the first byte alone must be held back, not released"); + } + } } } diff --git a/crypto/core/src/lib.rs b/crypto/core/src/lib.rs index a75792dc..53460b5c 100644 --- a/crypto/core/src/lib.rs +++ b/crypto/core/src/lib.rs @@ -9,4 +9,5 @@ pub mod errors; pub mod key_material; pub mod suspendable_state; +pub mod tagged_aead; pub mod traits; diff --git a/crypto/core/src/tagged_aead.rs b/crypto/core/src/tagged_aead.rs new file mode 100644 index 00000000..9874e172 --- /dev/null +++ b/crypto/core/src/tagged_aead.rs @@ -0,0 +1,529 @@ +//! Adapts an [`AEADCipherEncryptor`] / +//! [`AEADCipherDecryptor`] pair to the separate-output +//! [`SimpleCipherEncryptor`] / +//! [`SimpleCipherDecryptor`] shape by inlining the tag as +//! the last `TAG_LEN` bytes of the ciphertext stream -- the `ciphertext || tag` layout most wire +//! formats and files use, as opposed to the AEAD pair's own detached-tag shape. +//! +//! This is deliberately the *inverse* direction from every other adapter in this crate: instead +//! of adding capability (an AEAD's AAD, its generated nonce), it *drops* the AAD phase, because +//! [`SimpleCipherEncryptor`] has nowhere to carry one. An +//! AEAD wrapped here can still be driven with AAD through the inherent +//! [`TaggedEncryptor::do_update_aad`] / [`TaggedDecryptor::do_update_aad`], which forward to the +//! wrapped value's own method (see their docs for why this can't be part of the +//! `SimpleCipherEncryptor`/`SimpleCipherDecryptor` impl itself); a caller who does not need AAD +//! can ignore that entirely and use [`SimpleCipherEncryptor`]'s +//! full one-shot and streaming API unchanged. +//! +//! # Restricted to non-buffering ciphers +//! +//! Both adapters require the wrapped `FINAL_LEN` to be `0` -- nothing held back at +//! finalization -- which covers Ascon-AEAD128 and any other AEAD that releases every ciphertext +//! byte as soon as it produces it. A cipher that also buffers a partial final block would need +//! this adapter's own `FINAL_LEN` to be `INNER_FINAL_LEN + TAG_LEN`, a value derived from two +//! other const generics; Rust's stable const generics cannot express that as a trait argument +//! (it needs the still-incomplete `generic_const_exprs`), so supporting it is left to a future, +//! more general adapter. + +use crate::errors::SymmetricCipherError; +use crate::key_material::KeyMaterial; +use crate::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, + SimpleCipherDecryptor, SimpleCipherEncryptor, +}; + +/// Adapts an [`AEADCipherEncryptor`] with `FINAL_LEN = 0` to +/// [`SimpleCipherEncryptor`], appending the tag as the final segment +/// so the output stream is `ciphertext || tag`. See the module docs for the AAD caveat and the +/// `FINAL_LEN = 0` restriction. +pub struct TaggedEncryptor(E); + +impl TaggedEncryptor { + /// Absorbs `aad` on the wrapped encryptor; see + /// [`AEADCipherEncryptor::do_update_aad`] + /// for the rules (repeatable before the first `do_update_out`, an empty slice always a no-op). + /// Not part of the [`SimpleCipherEncryptor`] impl below, which has no AAD concept at all. + pub fn do_update_aad( + &mut self, + aad: &[u8], + ) -> Result<(), SymmetricCipherError> + where + E: AEADCipherEncryptor, + { + self.0.do_update_aad(aad) + } +} + +// Bounded on `Algorithm` alone, not the full `AEADCipherEncryptor` +// used below: those three consts appear only in a `where` clause, which Rust's coherence check +// does not accept as constraining an impl's generic parameters (E0207), and `Algorithm`'s own +// consts do not need them. +impl Algorithm for TaggedEncryptor { + const ALG_NAME: &'static str = E::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = E::MAX_SECURITY_STRENGTH; +} + +impl + SimpleCipherEncryptor for TaggedEncryptor +where + E: AEADCipherEncryptor, +{ + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + let (inner, nonce) = E::do_encrypt_init(key)?; + Ok((Self(inner), nonce)) + } + + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + let (inner, nonce) = E::do_encrypt_init_rng(key, rng)?; + Ok((Self(inner), nonce)) + } + + /// Identical to the wrapped encryptor's: this adapter never itself buffers, since the tag has + /// nowhere to go until `do_final`. + fn update_out_len(&self, input_len: usize) -> usize { + self.0.update_out_len(input_len) + } + + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + self.0.do_update_out(plaintext, ciphertext) + } + + /// Finishes the inner encryptor (with an empty flush buffer, since `FINAL_LEN = 0` on the + /// bound above) and returns its tag as this trait's own `FINAL_LEN`-byte final segment. + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + let mut nothing = [0u8; 0]; + let (flushed, tag) = self.0.do_encrypt_final(&mut nothing)?; + debug_assert_eq!(flushed, 0, "FINAL_LEN = 0 on the AEADCipherEncryptor bound"); + Ok((tag, TAG_LEN)) + } + + /// The plaintext length plus the tag: the inline layout this adapter produces. + fn encrypt_out_len(plaintext_len: usize) -> usize { + plaintext_len + TAG_LEN + } +} + +/// Adapts an [`AEADCipherDecryptor`] with `FINAL_LEN = 0` to +/// [`SimpleCipherDecryptor`], reading the tag as the last `TAG_LEN` +/// bytes of the ciphertext stream. `FINAL_LEN` here is `TAG_LEN` only to match +/// [`TaggedEncryptor`]'s own `FINAL_LEN` -- the pair contract [`SimpleCipherEncryptor`] / +/// [`SimpleCipherDecryptor`] share -- not because anything is actually flushed; see this type's +/// `do_final` impl. See the module docs for the AAD caveat and the wrapped AEAD's own +/// `FINAL_LEN = 0` restriction. +/// +/// # Holding back the tag +/// +/// The wire format gives no advance notice of where the ciphertext ends and the tag begins -- +/// that boundary is only known once the whole stream has been seen -- so this type holds back the +/// last `TAG_LEN` bytes it has been given at all times, in `tail`, releasing everything older than +/// that through the wrapped decryptor as soon as it is known not to be part of the tag. This is +/// the same technique `cli/src/ascon_cmd.rs`'s `aead128_decrypt_stream` used by hand before this +/// adapter existed. +pub struct TaggedDecryptor { + inner: D, + tail: [u8; TAG_LEN], + tail_len: usize, +} + +impl TaggedDecryptor { + /// Absorbs `aad` on the wrapped decryptor; see + /// [`AEADCipherDecryptor::do_update_aad`] + /// for the rules. Not part of the [`SimpleCipherDecryptor`] impl below, which has no AAD + /// concept at all. + pub fn do_update_aad( + &mut self, + aad: &[u8], + ) -> Result<(), SymmetricCipherError> + where + D: AEADCipherDecryptor, + { + self.inner.do_update_aad(aad) + } +} + +// See the equivalent impl on `TaggedEncryptor` for why this bounds on `Algorithm` alone. +impl Algorithm for TaggedDecryptor { + const ALG_NAME: &'static str = D::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = D::MAX_SECURITY_STRENGTH; +} + +impl + SimpleCipherDecryptor for TaggedDecryptor +where + D: AEADCipherDecryptor, +{ + fn do_decrypt_init( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ) -> Result { + Ok(Self { inner: D::do_decrypt_init(key, nonce)?, tail: [0u8; TAG_LEN], tail_len: 0 }) + } + + /// Only the bytes no longer eligible to be the tag: `tail_len + input_len - TAG_LEN`, floored + /// at `0` while the stream is still shorter than the tag itself. + fn update_out_len(&self, input_len: usize) -> usize { + (self.tail_len + input_len).saturating_sub(TAG_LEN) + } + + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + let releasable = self.update_out_len(ciphertext.len()); + if plaintext.len() < releasable { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("plaintext", releasable)); + } + + let total = self.tail_len + ciphertext.len(); + if total <= TAG_LEN { + // Everything seen so far might still be the tag; buffer it and release nothing. + self.tail[self.tail_len..total].copy_from_slice(ciphertext); + self.tail_len = total; + return Ok(0); + } + + // Release the old tail (in full, or as much of it as `releasable` allows) followed by + // however much of the new input is also releasable; two streaming calls into the wrapped + // decryptor, equivalent to one over their concatenation. + let from_tail = self.tail_len.min(releasable); + let from_new = releasable - from_tail; + if from_tail > 0 { + self.inner.do_update_out(&self.tail[..from_tail], &mut plaintext[..from_tail])?; + } + if from_new > 0 { + self.inner + .do_update_out(&ciphertext[..from_new], &mut plaintext[from_tail..releasable])?; + } + + // The new tail is whatever was not just released -- the suffix of the old tail, then the + // suffix of the new ciphertext -- which together are exactly TAG_LEN bytes, since + // `total - releasable == TAG_LEN` by construction of `releasable` above. + let mut new_tail = [0u8; TAG_LEN]; + let old_tail_kept = self.tail_len - from_tail; + new_tail[..old_tail_kept].copy_from_slice(&self.tail[from_tail..self.tail_len]); + new_tail[old_tail_kept..].copy_from_slice(&ciphertext[from_new..]); + self.tail = new_tail; + self.tail_len = TAG_LEN; + + Ok(releasable) + } + + /// Nothing is held back for release -- every plaintext byte was already emitted by + /// `do_update_out` -- so this is purely the tag check, against whatever ended up in `tail`. + /// The returned array is `FINAL_LEN = TAG_LEN` bytes only to match + /// [`TaggedEncryptor`]'s `FINAL_LEN` (the pair contract both traits share); the `0` data-byte + /// count says none of it is meaningful, exactly the case [`SimpleCipherDecryptor::do_final`]'s + /// own docs anticipate ("an authenticated cipher may release nothing at all once it has + /// checked the tag"). + /// + /// # Errors + /// [`SymmetricCipherError::DecryptionFailed`] if fewer than `TAG_LEN` bytes were ever seen (the + /// input was shorter than the tag). Otherwise, whatever + /// [`AEADCipherDecryptor::do_decrypt_final`] + /// returns, most notably [`SymmetricCipherError::AEADTagCheckFailed`]. + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + if self.tail_len < TAG_LEN { + return Err(SymmetricCipherError::DecryptionFailed); + } + let mut nothing = [0u8; 0]; + self.inner.do_decrypt_final(&self.tail, &mut nothing)?; + Ok(([0u8; TAG_LEN], 0)) + } + + /// The ciphertext length minus the tag, floored at `0` for an input shorter than the tag + /// (which `do_final` rejects rather than `do_update_out`, so the buffer must still be sized). + fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + ciphertext_len.saturating_sub(TAG_LEN) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key_material::{KeyMaterialTrait, KeyType, do_hazardous_operations}; + use crate::traits::RNG; + use bouncycastle_utils::secret::Secret; + + const KEY_LEN: usize = 4; + const NONCE_LEN: usize = 4; + const TAG_LEN: usize = 3; + + /// A toy AEAD: "ciphertext" is the plaintext XORed byte-by-byte with the key (cycled), and the + /// "tag" is a running XOR of every AAD/plaintext byte seen, repeated to `TAG_LEN` bytes. Not + /// remotely secure -- it exists only to drive `TaggedEncryptor`/`TaggedDecryptor` through + /// [`crate::traits::SimpleCipherEncryptor`]/[`SimpleCipherDecryptor`]'s chunked-equivalence + /// contract at exact byte-boundary edge cases around `TAG_LEN`, which is what this module's + /// hand-written tail bookkeeping needs pinned directly (see CLAUDE.md on testing + /// behaviour-critical private logic in-file). + #[derive(Clone)] + struct Toy { + key: Secret<[u8; KEY_LEN]>, + pos: usize, + acc: u8, + } + + impl Toy { + fn new(key: &KeyMaterial) -> Result { + let mut k = Secret::<[u8; KEY_LEN]>::new(); + k.copy_from_slice(key.ref_to_bytes()); + Ok(Self { key: k, pos: 0, acc: 0 }) + } + + /// Transforms `data` in place, accumulating `acc` over the *plaintext* byte on both + /// sides: encrypting, `data` starts as plaintext, so `acc` is updated before the XOR; + /// decrypting, `data` starts as ciphertext, so the XOR (which recovers the plaintext byte + /// into the same slot) must happen first. + fn transform(&mut self, data: &mut [u8], encrypting: bool) { + for b in data.iter_mut() { + if encrypting { + self.acc ^= *b; + } + *b ^= self.key[self.pos % KEY_LEN]; + if !encrypting { + self.acc ^= *b; + } + self.pos += 1; + } + } + } + + struct ToyEnc(Toy); + struct ToyDec(Toy); + + impl Algorithm for ToyEnc { + const ALG_NAME: &'static str = "toy-aead"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; + } + impl Algorithm for ToyDec { + const ALG_NAME: &'static str = "toy-aead"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; + } + + impl AEADCipherEncryptor for ToyEnc { + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Ok((Self(Toy::new(key)?), [0u8; NONCE_LEN])) + } + fn do_encrypt_init_rng( + key: &KeyMaterial, + _rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Self::do_encrypt_init(key) + } + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.0.acc ^= b; + } + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + if ciphertext.len() < plaintext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "ciphertext", + plaintext.len(), + )); + } + let out = &mut ciphertext[..plaintext.len()]; + out.copy_from_slice(plaintext); + self.0.transform(out, true); + Ok(plaintext.len()) + } + fn do_encrypt_final( + self, + _output: &mut [u8; 0], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + Ok((0, [self.0.acc; TAG_LEN])) + } + } + + impl AEADCipherDecryptor for ToyDec { + fn do_decrypt_init( + key: &KeyMaterial, + _nonce: &[u8; NONCE_LEN], + ) -> Result { + Ok(Self(Toy::new(key)?)) + } + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.0.acc ^= b; + } + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + if plaintext.len() < ciphertext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "plaintext", + ciphertext.len(), + )); + } + let out = &mut plaintext[..ciphertext.len()]; + out.copy_from_slice(ciphertext); + self.0.transform(out, false); + Ok(ciphertext.len()) + } + fn do_decrypt_final( + self, + tag: &[u8; TAG_LEN], + _output: &mut [u8; 0], + ) -> Result { + if [self.0.acc; TAG_LEN] != *tag { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok(0) + } + } + + fn key() -> KeyMaterial { + let mut km = + KeyMaterial::::from_bytes_as_type(&[1, 2, 3, 4], KeyType::SymmetricCipherKey) + .unwrap(); + do_hazardous_operations(&mut km, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::None) + }) + .unwrap(); + km + } + + /// The one-shot round trip through the adapters, at every message length crossing a few + /// multiples of `TAG_LEN`, and every chunking of `do_update_out` on both sides -- this is what + /// pins the tail bookkeeping's off-by-one edges directly, complementing the framework's own + /// generic `test_encryptor_decryptor` coverage (which this same adapter pair is expected to + /// pass against `SimpleCipherEncryptor`/`SimpleCipherDecryptor`'s contract elsewhere). + #[test] + fn tagged_round_trip_at_every_length_and_chunking() { + let km = key(); + for len in 0..=(4 * TAG_LEN + 5) { + let msg: Vec = + (0..len).map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)).collect(); + + let (mut enc, nonce) = as SimpleCipherEncryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_encrypt_init(&km) + .unwrap(); + enc.do_update_aad::(b"aad").unwrap(); + let mut ct = vec![0u8; msg.len() + TAG_LEN]; + for chunk in [1usize, 2, 3, TAG_LEN.max(1), len.max(1)] { + let mut enc = { + let (mut e, _) = as SimpleCipherEncryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_encrypt_init(&km) + .unwrap(); + e.do_update_aad::(b"aad").unwrap(); + e + }; + let mut written = 0; + for piece in msg.chunks(chunk) { + written += enc.do_update_out(piece, &mut ct[written..]).unwrap(); + } + let mut last = [0u8; TAG_LEN]; + let last_len = as SimpleCipherEncryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_final_out(enc, &mut last) + .unwrap(); + ct[written..written + last_len].copy_from_slice(&last[..last_len]); + written += last_len; + ct.truncate(written); + + let mut dec = as SimpleCipherDecryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_decrypt_init(&km, &nonce) + .unwrap(); + dec.do_update_aad::(b"aad").unwrap(); + let mut pt = vec![0u8; ct.len()]; + let mut written = 0; + for piece in ct.chunks(chunk) { + written += dec.do_update_out(piece, &mut pt[written..]).unwrap(); + } + let (_, data_len) = dec.do_final().unwrap(); + pt.truncate(written + data_len); + assert_eq!(pt, msg, "len {len}, chunk {chunk}"); + + ct.resize(msg.len() + TAG_LEN, 0); + } + } + } + + /// A tampered inline stream must fail at `do_final`, and a stream shorter than the tag must be + /// rejected as `DecryptionFailed` rather than panicking on the short slice. + #[test] + fn tampering_and_short_input_are_rejected() { + let km = key(); + let (mut enc, nonce) = as SimpleCipherEncryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_encrypt_init(&km) + .unwrap(); + let mut ct = vec![0u8; 10 + TAG_LEN]; + let written = enc.do_update_out(&[7u8; 10], &mut ct).unwrap(); + let mut last = [0u8; TAG_LEN]; + let last_len = as SimpleCipherEncryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_final_out(enc, &mut last) + .unwrap(); + ct[written..written + last_len].copy_from_slice(&last[..last_len]); + + let mut tampered = ct.clone(); + tampered[0] ^= 0xFF; + let mut dec = as SimpleCipherDecryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_decrypt_init(&km, &nonce) + .unwrap(); + let mut pt = vec![0u8; tampered.len()]; + let mut written = 0; + written += dec.do_update_out(&tampered, &mut pt[written..]).unwrap(); + let _ = written; + assert!(matches!(dec.do_final(), Err(SymmetricCipherError::AEADTagCheckFailed))); + + for short_len in 0..TAG_LEN { + let dec = as SimpleCipherDecryptor< + KEY_LEN, + NONCE_LEN, + TAG_LEN, + >>::do_decrypt_init(&km, &nonce) + .unwrap(); + let mut dec = dec; + let mut pt = vec![0u8; short_len]; + dec.do_update_out(&ct[..short_len], &mut pt).unwrap(); + assert!(matches!(dec.do_final(), Err(SymmetricCipherError::DecryptionFailed))); + } + } +} diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index adc702c5..4fc5219b 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -55,8 +55,11 @@ pub trait AEADCipher`, so it needs the `std` feature. /// /// # Errors - /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. The caller learns - /// only that decryption failed. + /// [`SymmetricCipherError::DecryptionFailed`] if the ciphertext does not authenticate. This + /// view has no AAD and no separate tag to name, so it reports every authentication failure + /// this way rather than as [`SymmetricCipherError::AEADTagCheckFailed`], which is reserved for + /// [`aead_decrypt`](Self::aead_decrypt) / [`aead_decrypt_out`](Self::aead_decrypt_out); either + /// way, the caller learns only that decryption failed, not why. fn decrypt( key: &KeyMaterial, init_data: [u8; NONCE_LEN], @@ -100,10 +103,14 @@ pub trait AEADCipher Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError>; - /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a stream cipher ([`StreamCipherEncryptor`] / [`StreamCipherDecryptor`]), and so will already - /// have a streaming API. - /// This allows you to finish either style of streaming API flow with AEAD specific do_final() - /// that computes and returns the authentication tag. + /// Finishes a streaming encryption flow with an AEAD-specific `do_final()` that computes and + /// returns the authentication tag. + /// + /// An AEAD's own streaming API is [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], which has + /// this step (as [`AEADCipherEncryptor::do_encrypt_final`]) and an AAD phase of its own; this + /// method is for an implementor that streams through one of the unauthenticated cipher traits + /// -- [`BlockCipherEncryptor`] / [`BlockCipherDecryptor`] or [`StreamCipherEncryptor`] / + /// [`StreamCipherDecryptor`] -- and needs somewhere to put the tag. fn do_aead_encrypt_final(self) -> Result<[u8; TAG_LEN], SymmetricCipherError>; #[cfg(feature = "std")] /// A one-shot API to decrypt some ciphertext with the given key. @@ -129,13 +136,374 @@ pub trait AEADCipher Result; - /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a stream cipher ([`StreamCipherEncryptor`] / [`StreamCipherDecryptor`]), and so will already - /// have a streaming API. - /// This allows you to finish either style of streaming API flow with AEAD specific do_final() - /// that computes and returns the authentication tag. + /// Finishes a streaming decryption flow by checking `tag`; the mirror of + /// [`do_aead_encrypt_final`](Self::do_aead_encrypt_final), and see it for when this is the + /// right finalizer rather than [`AEADCipherDecryptor::do_decrypt_final`]. fn do_aead_decrypt_final(self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError>; } +/// The decryption half of an AEAD cipher's streaming API; see [`AEADCipherEncryptor`], whose notes +/// on the AAD phase, buffering, and the `Result` all apply here too. +/// +/// # The plaintext is not authenticated until `do_decrypt_final` returns `Ok` +/// +/// This is the one thing a streaming AEAD API cannot hide from its caller. +/// [`do_update_out`](Self::do_update_out) releases plaintext as soon as it can, long before there +/// is a tag to check it against, so a caller that *uses* those bytes before +/// [`do_decrypt_final`](Self::do_decrypt_final) has returned `Ok` is acting on unauthenticated +/// plaintext -- bytes an attacker may have chosen. Preventing exactly that is what the tag is for. +/// A streaming caller must therefore treat everything `do_update_out` produces as untrusted until +/// the final call succeeds, and scrub it if it does not. +/// +/// The one-shot [`decrypt`](Self::decrypt) has no such caveat: it owns the whole message, so it +/// zeroizes the buffer itself before returning the error. +pub trait AEADCipherDecryptor< + const KEY_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const FINAL_LEN: usize, +>: Algorithm + Sized +{ + /// Begins a streaming decryption flow from the nonce returned by + /// [`AEADCipherEncryptor::do_encrypt_init`]. + /// + /// # Errors + /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose + /// security strength is below [`Algorithm::MAX_SECURITY_STRENGTH`], both as a + /// [`SymmetricCipherError::KeyMaterialError`]. + fn do_decrypt_init( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ) -> Result; + + /// Absorbs additional authenticated data; see [`AEADCipherEncryptor::do_update_aad`] for the + /// rules, which are the same on both sides. The concatenation of what a decryptor absorbs must + /// be byte-for-byte the concatenation the encryptor absorbed, or the tag check fails. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if called with a non-empty `aad` after + /// [`do_update_out`](Self::do_update_out). + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError>; + + /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if + /// given `input_len` more bytes of ciphertext. Depends on what is already buffered; identically + /// `0` for a cipher that never holds anything back, such as Ascon-AEAD128. + fn update_out_len(&self, input_len: usize) -> usize; + + /// Streaming: consumes `ciphertext`, writing every plaintext byte that can be released so far + /// into `plaintext` and buffering the rest. Returns the number of bytes written, which is + /// exactly [`update_out_len`](Self::update_out_len) of `ciphertext.len()`. + /// + /// The bytes this writes are *not* yet authenticated; see the trait docs. A decryptor may have + /// to hold back the tail of what it has seen -- a block-oriented cipher's partial final block, + /// or the bytes that might turn out to be an inline tag -- so a sequence of calls releases data + /// later than the corresponding encryptor produced it, but the concatenation of everything + /// released, in any chunking, plus the data part of + /// [`do_decrypt_final`](Self::do_decrypt_final), is the plaintext. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `plaintext` is shorter than + /// [`update_out_len`](Self::update_out_len), carrying the required length. Nothing is + /// consumed in that case. + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result; + + /// Finishes the decryption, consuming the decryptor: flushes whatever ciphertext was held back + /// into `output`, computes the tag over the AAD and ciphertext it has seen, and compares it + /// against `tag`. Returns how many leading bytes of `output` are plaintext; the remainder is + /// not data and must not be used. `Ok` is the only thing that makes those bytes -- or anything + /// already released by [`do_update_out`](Self::do_update_out) -- trustworthy. + /// + /// # Errors + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. Implementors must + /// compare in constant time, and the caller learns only that the check failed. + fn do_decrypt_final( + self, + tag: &[u8; TAG_LEN], + output: &mut [u8; FINAL_LEN], + ) -> Result; + + /// An upper bound on the plaintext recovered from `ciphertext_len` bytes of ciphertext, i.e. + /// the buffer [`decrypt_out`](Self::decrypt_out) requires. The default returns `ciphertext_len` + /// itself, which is exact for every conformant AEAD: unlike a padding scheme, an AEAD never + /// expands or shrinks the data it is given, only adds the separate `tag`. + fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + ciphertext_len + } + + /// One-shot: decrypts `ciphertext` into `plaintext`, which needs + /// [`decrypt_out_max_len`](Self::decrypt_out_max_len) bytes, under `nonce` and `aad`, and + /// checks `tag`. Returns the number of plaintext bytes written. + /// + /// Unlike the streaming methods this releases nothing unauthenticated: on failure `plaintext` + /// is zeroized before the error is returned, so a caller who ignores the `Result` is left with + /// zeros rather than attacker-chosen plaintext. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `plaintext` is too short, checked + /// before any work is done; otherwise whatever the streaming methods return, including + /// [`do_decrypt_final`](Self::do_decrypt_final)'s. + fn decrypt_out( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], + plaintext: &mut [u8], + ) -> Result { + let needed = Self::decrypt_out_max_len(ciphertext.len()); + if plaintext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("plaintext", needed)); + } + let mut dec = Self::do_decrypt_init(key, nonce)?; + dec.do_update_aad(aad)?; + let written = dec.do_update_out(ciphertext, plaintext)?; + let mut final_buf = [0u8; FINAL_LEN]; + match dec.do_decrypt_final(tag, &mut final_buf) { + Ok(final_len) => { + plaintext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); + Ok(written + final_len) + } + Err(e) => { + // As in the trait docs: what `do_update_out` already released is unauthenticated, + // and this one-shot owns the whole message, so it does not leave that in the + // caller's hands. A plain `fill` rather than a volatile write because `core` is + // `#![forbid(unsafe_code)]`; the store is to the caller's own buffer, which the + // caller may read after this returns, so it is not a dead store the optimizer is + // entitled to drop. + plaintext[..written].fill(0); + Err(e) + } + } + } + + #[cfg(feature = "std")] + /// One-shot, allocating: as [`decrypt_out`](Self::decrypt_out), returning the plaintext as a + /// `Vec` of exactly the recovered length. Only available with the `std` feature. + fn decrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], + ) -> Result, SymmetricCipherError> { + let mut plaintext = vec![0u8; Self::decrypt_out_max_len(ciphertext.len())]; + let written = Self::decrypt_out(key, nonce, aad, ciphertext, tag, &mut plaintext)?; + plaintext.truncate(written); + Ok(plaintext) + } +} + +/// The encryption half of an AEAD cipher's streaming API. This is the AEAD counterpart of +/// [`SimpleCipherEncryptor`] -- the same separate-output, init-data-generating, possibly-buffering +/// shape -- with the two differences that authentication forces. +/// +/// The first is an extra phase. An AEAD authenticates data it does not encrypt -- additional +/// authenticated data (AAD), typically a header that has to travel in the clear but must still be +/// protected against tampering -- and every AEAD construction absorbs that AAD *before* the +/// plaintext. So [`do_update_aad`](Self::do_update_aad) may be called any number of times after +/// the constructor and before the first [`do_update_out`](Self::do_update_out), and returns +/// [`SymmetricCipherError::StateError`] thereafter. (An empty `aad` slice is a no-op and is +/// accepted at any point, so a generic caller may pass one unconditionally.) That is a runtime +/// error for the same reason [`XOF`] rejects absorb-after-squeeze at runtime: the phase order is a +/// property of a value's history, and encoding it in the type would cost every implementor an +/// extra type and an explicit transition. +/// +/// The second is a finalization step that also produces a tag: [`do_encrypt_final`](Self::do_encrypt_final) +/// consumes the encryptor, flushes whatever ciphertext it was holding back into `output`, and +/// returns the tag, which the recipient needs for [`AEADCipherDecryptor::do_decrypt_final`]. Where +/// the tag travels -- appended to the ciphertext, carried in a separate field -- is the caller's +/// choice, not this trait's; contrast [`AEADCipher`], whose one-shots pick a layout for you, and +/// see `bouncycastle_core::tagged_aead` for an adapter that appends it. +/// +/// Encryption and decryption are separate traits, as with [`BlockCipherEncryptor`] / +/// [`BlockCipherDecryptor`], so that the direction is encoded in the type. For an AEAD that also +/// buys away a class of runtime check: a single type serving both directions has to remember which +/// one it is and refuse the other's methods, whereas a paired-type implementation cannot be asked +/// the question. +/// +/// # The nonce is generated, not supplied +/// +/// The constructor draws the nonce itself and returns it for transmission alongside the ciphertext; +/// there is no API here for the caller to choose one, for the same reason as in +/// [`BlockCipherEncryptor`], but with sharper consequences. Reusing a nonce under one key does not +/// merely leak equality of plaintexts as it does for an unauthenticated mode -- for most AEAD +/// constructions it forfeits confidentiality of the affected messages and can expose the material +/// the tag is computed from, costing authenticity for every other message under that key. A caller +/// who genuinely needs a deterministic, caller-chosen nonce (to follow a protocol's construction, +/// or to run a spec's test vectors) should see the documentation of the underlying implementation, +/// which is where that hazard belongs. +/// +/// # A cipher may buffer +/// +/// [`do_update_out`](Self::do_update_out) takes separate input and output buffers, because an AEAD +/// is not guaranteed to release a ciphertext byte the moment it sees the matching plaintext byte. +/// Ascon-AEAD128 does -- each rate-block byte is transformed independently of the others in that +/// block -- but a block-oriented AEAD holds back a partial final block, and any AEAD adapted to an +/// inline `ciphertext || tag` layout must hold back at least `TAG_LEN` bytes until it knows they +/// are not the tag (see `bouncycastle_core::tagged_aead`). [`update_out_len`](Self::update_out_len) +/// answers exactly how many bytes the next call releases, so a caller never has to guess a buffer +/// size or find plaintext left over at the end of one it guessed too large; the concatenation of +/// everything released, in any chunking, plus the data part of +/// [`do_encrypt_final`](Self::do_encrypt_final), is the ciphertext. +/// +/// # Any length, as a slice +/// +/// [`do_update_out`](Self::do_update_out)'s input is a `&[u8]` rather than a `&[u8; LEN]` because +/// every length is valid, including zero, so there is no invariant for a const parameter to carry +/// and nothing for a compile-time check to check -- the same reasoning as +/// [`StreamCipherEncryptor`], and the reason there is no `BLOCK_LEN` here. +/// +/// # Why the data methods still return `Result` +/// +/// Nothing about the buffer can go wrong, and a constructed value is always ready to use, so +/// [`do_update_out`](Self::do_update_out) has nothing to report for most ciphers. The `Result` is +/// for the per-(key, nonce) data limit an AEAD generally has -- past it the construction's security +/// argument no longer holds -- which a streaming API cannot check any earlier than the call that +/// would cross it, and for [`IncorrectOutputBufferLength`](SymmetricCipherError::IncorrectOutputBufferLength) +/// if the caller under-sized `ciphertext`. +pub trait AEADCipherEncryptor< + const KEY_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const FINAL_LEN: usize, +>: Algorithm + Sized +{ + /// Begins a streaming encryption flow, returning the encryptor and the generated nonce, which + /// the recipient needs for [`AEADCipherDecryptor::do_decrypt_init`]. Sources randomness from + /// the library's default OS-backed RNG. + /// + /// # Errors + /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose + /// security strength is below [`Algorithm::MAX_SECURITY_STRENGTH`], both as a + /// [`SymmetricCipherError::KeyMaterialError`]; a failure to draw the nonce comes back as a + /// [`SymmetricCipherError::RNGError`]. + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError>; + + /// As [`do_encrypt_init`](Self::do_encrypt_init), but sources randomness from the provided RNG. + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError>; + + /// Absorbs `aad`: data that is authenticated by the tag but not encrypted. May be called + /// repeatedly before the first [`do_update_out`](Self::do_update_out); a sequence of calls is + /// equivalent to one call over the concatenation. An empty `aad` is a no-op. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if called with a non-empty `aad` after + /// [`do_update_out`](Self::do_update_out) -- see the trait docs for why the AAD comes first. + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError>; + + /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if + /// given `input_len` more bytes of plaintext. Depends on what is already buffered; identically + /// `0` for a cipher that never holds anything back, such as Ascon-AEAD128. + fn update_out_len(&self, input_len: usize) -> usize; + + /// Streaming: consumes `plaintext`, writing every ciphertext byte that can be produced so far + /// into `ciphertext` and buffering the rest. Returns the number of bytes written, which is + /// exactly [`update_out_len`](Self::update_out_len) of `plaintext.len()`. A sequence of calls + /// is equivalent to one call over the concatenation, whatever the chunking. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `ciphertext` is shorter than + /// [`update_out_len`](Self::update_out_len), carrying the required length. Nothing is + /// consumed in that case. + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result; + + /// Finishes the encryption, consuming the encryptor: flushes whatever plaintext was held back, + /// encrypted, into `output`, and returns how many leading bytes of it are ciphertext together + /// with the tag over the AAD and plaintext it has seen. The tag must be transmitted with the + /// ciphertext; the recipient passes it to [`AEADCipherDecryptor::do_decrypt_final`]. + fn do_encrypt_final( + self, + output: &mut [u8; FINAL_LEN], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError>; + + /// The exact ciphertext length for a `plaintext_len`-byte plaintext, i.e. the buffer + /// [`encrypt_out`](Self::encrypt_out) requires and the number of bytes it writes (the tag is + /// returned separately, not counted here). The default returns `plaintext_len` itself, which + /// holds for every conformant AEAD: unlike a padding scheme, an AEAD never expands or shrinks + /// the data it is given. + fn encrypt_out_len(plaintext_len: usize) -> usize { + plaintext_len + } + + /// One-shot: encrypts `plaintext` into `ciphertext`, which needs + /// [`encrypt_out_len`](Self::encrypt_out_len) bytes, authenticating `aad` along with it under a + /// fresh nonce. Returns the generated nonce, the number of bytes written, and the tag. + /// + /// Provided as `do_encrypt_init`, one `do_update_aad`, one `do_update_out` and + /// `do_encrypt_final`. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `ciphertext` is too short, checked + /// before any work is done; otherwise whatever the streaming methods return. + fn encrypt_out( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { + let needed = Self::encrypt_out_len(plaintext.len()); + if ciphertext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("ciphertext", needed)); + } + let (mut enc, nonce) = Self::do_encrypt_init(key)?; + enc.do_update_aad(aad)?; + let written = enc.do_update_out(plaintext, ciphertext)?; + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; + // `encrypt_out_len` bounds `written + final_len`, so this fits in `ciphertext[..needed]`. + ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); + Ok((nonce, written + final_len, tag)) + } + + /// As [`encrypt_out`](Self::encrypt_out), but sources randomness from the provided RNG. + fn encrypt_out_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { + let needed = Self::encrypt_out_len(plaintext.len()); + if ciphertext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("ciphertext", needed)); + } + let (mut enc, nonce) = Self::do_encrypt_init_rng(key, rng)?; + enc.do_update_aad(aad)?; + let written = enc.do_update_out(plaintext, ciphertext)?; + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; + ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); + Ok((nonce, written + final_len, tag)) + } + + #[cfg(feature = "std")] + /// One-shot, allocating: as [`encrypt_out`](Self::encrypt_out), returning the ciphertext as a + /// `Vec`. Only available with the `std` feature. + fn encrypt( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ) -> Result<([u8; NONCE_LEN], Vec, [u8; TAG_LEN]), SymmetricCipherError> { + let mut ciphertext = vec![0u8; Self::encrypt_out_len(plaintext.len())]; + let (nonce, written, tag) = Self::encrypt_out(key, aad, plaintext, &mut ciphertext)?; + ciphertext.truncate(written); + Ok((nonce, ciphertext, tag)) + } +} + /// Metadata about a cryptographic algorithm. pub trait Algorithm { /// String name for the algorithm, used consistently across the library. From 120b2fe2201ec2f02a6e1f716dab1958436bfc03 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 9 Sep 2026 23:59:18 +0700 Subject: [PATCH 02/16] ascon, cli: add bouncycastle-ascon (SP 800-232 Ascon-AEAD128/Hash256/XOF128/CXOF128) implementing AEADCipherEncryptor/AEADCipherDecryptor via AsconAead128Encryptor/AsconAead128Decryptor, with HashFactory/XOFFactory registration and CLI wiring including a TaggedDecryptor-based decrypt stream --- Cargo.toml | 2 + alpha_0.1.3_release_notes.md | 558 ++++++++++++- cli/src/ascon_cmd.rs | 194 +++++ cli/src/helpers.rs | 34 +- cli/src/main.rs | 83 ++ cli/src/sha3_cmd.rs | 36 +- cli/tests/ascon_cli_tests.rs | 308 ++++++++ crypto/ascon/Cargo.toml | 25 + crypto/ascon/benches/ascon_benches.rs | 93 +++ crypto/ascon/src/ascon_aead128.rs | 865 +++++++++++++++++++++ crypto/ascon/src/ascon_cxof128.rs | 218 ++++++ crypto/ascon/src/ascon_hash256.rs | 185 +++++ crypto/ascon/src/ascon_xof128.rs | 172 ++++ crypto/ascon/src/lib.rs | 137 ++++ crypto/ascon/src/permutation.rs | 138 ++++ crypto/ascon/src/sponge.rs | 189 +++++ crypto/ascon/tests/aead128_tests.rs | 768 ++++++++++++++++++ crypto/ascon/tests/bc_test_data.rs | 242 ++++++ crypto/ascon/tests/cxof128_tests.rs | 221 ++++++ crypto/ascon/tests/hash256_tests.rs | 152 ++++ crypto/ascon/tests/xof128_tests.rs | 183 +++++ crypto/factory/Cargo.toml | 1 + crypto/factory/src/hash_factory.rs | 17 + crypto/factory/src/xof_factory.rs | 45 +- crypto/factory/tests/hash_factory_tests.rs | 24 + crypto/factory/tests/xof_factory_tests.rs | 115 ++- src/lib.rs | 1 + 27 files changed, 4950 insertions(+), 56 deletions(-) create mode 100644 cli/src/ascon_cmd.rs create mode 100644 cli/tests/ascon_cli_tests.rs create mode 100644 crypto/ascon/Cargo.toml create mode 100644 crypto/ascon/benches/ascon_benches.rs create mode 100644 crypto/ascon/src/ascon_aead128.rs create mode 100644 crypto/ascon/src/ascon_cxof128.rs create mode 100644 crypto/ascon/src/ascon_hash256.rs create mode 100644 crypto/ascon/src/ascon_xof128.rs create mode 100644 crypto/ascon/src/lib.rs create mode 100644 crypto/ascon/src/permutation.rs create mode 100644 crypto/ascon/src/sponge.rs create mode 100644 crypto/ascon/tests/aead128_tests.rs create mode 100644 crypto/ascon/tests/bc_test_data.rs create mode 100644 crypto/ascon/tests/cxof128_tests.rs create mode 100644 crypto/ascon/tests/hash256_tests.rs create mode 100644 crypto/ascon/tests/xof128_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 63f0d999..7aa567d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ version = "0.1.3" # *** Internal Dependencies *** bouncycastle = { path = "./" } bouncycastle-aes = { path = "./crypto/aes" } +bouncycastle-ascon = { path = "./crypto/ascon" } bouncycastle-base64 = { path = "./crypto/base64" } bouncycastle-modes = { path = "./crypto/modes" } bouncycastle-core = { path = "crypto/core" } @@ -46,6 +47,7 @@ edition.workspace = true [dependencies] bouncycastle-aes.workspace = true +bouncycastle-ascon.workspace = true bouncycastle-base64.workspace = true bouncycastle-core.workspace = true bouncycastle-factory.workspace = true diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index d5185528..17b6e1fa 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -2,9 +2,561 @@ ## Major features -* New algorithms added to crypto/ : - * SM3 -- the SM3 hash (GB/T 32905-2016 / ISO/IEC 10118-3:2018), ported from bc-java. - * AES -- AES-128/192/256, along with its modes AES_ECB, AES_CBC, AES_GCM. +* New algorithms added to crypto/ (PR #89): + * sm3 -- the SM3 hash (GB/T 32905-2016 / ISO/IEC 10118-3:2018), ported from bc-java. Implements `Hash`, + `Suspendable` and `AlgorithmOID`, supports bit-oriented (partial final byte) messages per GB/T 32905-2016 s. 5.2 + with the partial byte in ASN.1 BIT STRING order like SHA-2/SHA-3, and is registered in `HashFactory` + (`"SM3"`) with a `bc-rust sm3` CLI subcommand. + * HMAC-SM3, in the hmac crate, registered in `MACFactory` (`"HMAC-SM3"`) with a `bc-rust hmac-sm3` CLI subcommand. + * Test vectors are the GB/T 32905-2016 Appendix A examples plus the bc-java `SM3DigestTest` / `HMac` vectors, with + additional digests cross-checked against OpenSSL and bc-java. + +New crate `bouncycastle-aes` (`bouncycastle::aes`): AES-128/192/256 as a raw keyed block +permutation (NIST FIPS 197), re-exported from the umbrella crate. + +* **Constant-time and table-free.** The S-box is evaluated as a Boolean circuit -- the 113-gate Boyar-Peralta + straight-line program, 32 AND / 77 XOR / 4 XNOR -- over eight `u32` bit-planes, so there is no secret-indexed + memory access and no secret-dependent branch anywhere, including in the key schedule. A table-driven "light" + AES that removes the tables only from the cipher still leaks through `SUBWORD()` in the expansion. +* **Low memory.** No lookup tables at all (0 bytes, against 512 bytes for BC Java's `AESLightEngine` and 2-8 KiB + for T-table engines) and no heap allocation. The only persistent state is the key schedule, stored bit-sliced + in a compressed form that is exactly the FIPS 197 Sec 5.2 size: `AES_128` 176 B, `AES_192` 208 B, `AES_256` 240 B. +* **Both directions from one value.** Decryption follows FIPS 197 Algorithm 3 (the straight inverse cipher) rather + than the equivalent inverse cipher of Sec 5.3.5, so it uses the unmodified key schedule -- one stored schedule + encrypts and decrypts, with no second copy and no transformation at construction time. +* **Two-block entry points.** The bit-sliced state holds two blocks, so `encrypt_2blocks` / `decrypt_2blocks` are + the natural unit of work and roughly double single-block throughput. `encrypt_block` / `decrypt_block` are + provided but do twice the necessary work; modes whose blocks are independent (CTR, and CBC/CFB decryption) + should prefer the pair form. +* Verified against FIPS 197 Appendix A.1/A.2/A.3 (every schedule word), FIPS 197 Appendix B, an exhaustive check + of all 256 S-box and inverse S-box inputs against Tables 4 and 6, SP 800-38A Appendix F.1 (ECB, all three key + lengths, both directions), and 2138 NIST ACVP `ACVP-AES-ECB` cases from `bc-test-data` (skipped with a warning + if that repository is not checked out). +* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can + only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security + strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. +* Ships the type aliases `AES_CBC_128` / `AES_CBC_192` / `AES_CBC_256`, `AES_CFB_128` / + `AES_CFB_192` / `AES_CFB_256`, `AES_CFB8_128` / `AES_CFB8_192` / `AES_CFB8_256`, + `AES_CTR_128` / `AES_CTR_192` / `AES_CTR_256` (12-byte nonce, 4-byte counter) and + `AES_ECB_128` / `AES_ECB_192` / `AES_ECB_256`, which fill in the + const parameters of `bouncycastle-modes`' `Cbc`, `Cfb`, `Cfb8`, `Ctr` and `Ecb`. The three stream + modes leave the direction as the only type parameter; the two **block** modes, CBC and ECB, take + a padding scheme as well -- `AES_CBC_128` -- because neither is defined on data + that is not a whole number of blocks, so the scheme is a choice the caller has to make and one + both ends must agree on. Naming it in the type makes a mismatched pair a compile error instead of + a decryption that returns plausible rubbish. `PaddedMode` is the crate-internal projection that lets a single + alias carry both parameters, `PaddedEncryptor` and `PaddedDecryptor` being distinct types. They are aliases only -- no new engine + code, and each one's doctest round-trips and shows that a misaligned length fails to compile. + +New crate `bouncycastle-modes` (`bouncycastle::modes`): cipher modes of operation +(NIST SP 800-38A), providing **CBC** (Sec 6.2), **CFB128** and **CFB8** (Sec 6.3, `s = b` and +`s = 8`), **CTR** (Sec 6.5) and **ECB** (Sec 6.1) -- four of the recommendation's five modes, with +only OFB outstanding. Re-exported from the umbrella crate. + +* `Cbc`, `Cfb`, `Cfb8` and `Ecb`, each ``, and `Ctr`, which takes a + nonce length as a fifth parameter, over any + `ElectronicCodeBook`, so the crate depends on no concrete cipher. The direction is a type parameter: + the encryptor trait is implemented only for `<_, Encrypting, _, _>` and the decryptor trait + only for `<_, Decrypting, _, _>`, making a wrong-direction call a compile error rather than a + runtime check. +* **Block modes and stream modes.** `Cbc` and `Ecb` are block ciphers + (`BlockCipherEncryptor` / `BlockCipherDecryptor`): whole blocks in, whole blocks out, with + arbitrary-length data going through `bouncycastle-padding`. `Cfb`, `Cfb8` and `Ctr` are stream + ciphers (`StreamCipherEncryptor` / `StreamCipherDecryptor`): any length in, the same length out, + no padding layer and no finalization step. That split follows SP 800-38A Sec 5.2, which requires a + multiple of the *block* size only for ECB and CBC, a multiple of the *segment* size `s` for CFB, + and nothing at all for CTR ("the plaintext need not be a multiple of the block size"). +* **The IV is generated, never accepted.** SP 800-38A Sec 5.3 requires the CBC *and CFB* IV to be + *unpredictable*, not merely unique, so `do_encrypt_init` draws one from the library's default + OS-backed DRBG (Appendix C's second recommended method) and returns it; there is no API for + supplying your own. Known-answer tests drive `do_encrypt_init_rng` with a fixed-output test RNG. + This matters more for CFB than for CBC: CFB XORs a keystream, so a repeated key-and-IV pair leaks + `P1 XOR P1'` outright rather than merely whether the blocks were equal. +* **Parallel decryption.** Sec 6.2 notes CBC decryption's inverse cipher calls can run in + parallel, so `do_decrypt_blocks` walks the ciphertext in fours through + `ElectronicCodeBook::decrypt_4blocks`, then pairs through `decrypt_2blocks`, then a one-block + remainder. A toy permutation that rotates its four results proves the four path is taken, and + only for full fours. Measured against an + otherwise identical permutation that does not override the pair methods, this is **1.83x** the + decryption throughput (67.9 vs 37.1 MiB/s, AES-128, 16 KiB, N=8). CBC encryption is serial by + construction and does not use it. +* Strictly block-aligned, as Sec 5.2 requires of CBC. Arbitrary-length data goes through + `bouncycastle-padding`'s `PaddedEncryptor` / `PaddedDecryptor`, which wrap either mode; no padding + logic lives in this crate. `crypto/modes/tests/cfb_tests.rs` round-trips every length from 0 to + `3 * BLOCK_LEN + 1` through PKCS7 to pin that the two crates compose. +* Verified against all six SP 800-38A Appendix F.2 vectors (CBC-AES128/192/256, Encrypt and + Decrypt), each checked in one call, one block at a time, in a `3 + 1` grouping that exercises the + pair remainder, and through the `_out` variant. Appendix D error propagation is tested + exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and + for a ciphertext bit error (affects exactly two blocks). +* Also verified against the **2150 NIST ACVP `ACVP-AES-CBC` AFT cases** from `bc-test-data` (all + three key lengths, both directions, 60 of them spanning 2-10 blocks). Each case is run twice -- + block by block, and in pairs with a one-block remainder -- so the `decrypt_2blocks` path is + exercised against real vectors, not only against the toy permutation. Unlike the ECB response + file, the CBC one carries only the answer against a `tcId`, so the request and response files are + joined; the 6 MCT groups are skipped and the count reported. These vectors were already in + `bc-test-data` and previously unused. +CFB128 (`Cfb`), SP 800-38A Sec 6.3 with `s = b`: + +* **A stream cipher.** Sec 6.3 parameterises CFB by a segment size `s` with `1 <= s <= b`, and + `Cfb` implements `s = b` -- CFB128 for AES. With `s = b` the spec's + `LSB_{b-s}(I_{j-1}) | C#_{j-1}` collapses to `Ij = C_{j-1}` and `MSB_s(Oj)` to `Oj`, which the + module docs derive step by step. CFB never puts the data through the cipher, only the input + block, so `Cfb` implements `StreamCipherEncryptor` / `StreamCipherDecryptor`: a `&mut [u8]` of + any length, in place, chunked however the caller likes, with no padding layer. +* **The short final segment.** Sec 5.2 defines CFB only on a multiple of `s`, and Appendix A puts + padding outside the recommendation's scope. Rather than reject a message that is not a whole + number of blocks, `Cfb` takes the `s = 8r` step of the Sec 6.3 equations for the last segment + alone -- `C#_n = P#_n XOR MSB_{8r}(On)` -- discarding the rest of `On` exactly as Sec 6.3 + discards `b - s` bits of every output block when `s < b`. No input block is formed after the last + segment, so the feedback rule that distinguishes `s < b` from `s = b` is never reached and the + result is unambiguous. This is what streaming CFB128 implementations do in practice, and the + ciphertexts interoperate: checked byte for byte against OpenSSL's `EVP_aes_128_cfb128` on a + 37-byte message, in both directions. +* **One buffer, three roles.** Within a segment the single stored block holds the ciphertext + produced so far and the unused tail of `Oj` at once -- each ciphertext byte is written over the + keystream byte that produced it, and is exactly what the next input block wants in that position + -- so the same 16 bytes are the input block, then the output block, then the next input block, + with no copy and no second buffer. That costs one `usize` over `Cbc` (200/232/264 B for + AES-128/192/256) to record how much of the current segment has been used. +* **Decryption uses the forward cipher function.** Sec 6.3 applies `CIPH_K` in both directions, so + `Cfb<_, Decrypting, _, _>` never calls `decrypt_block` or `decrypt_2blocks`. This is pinned by a + test permutation whose inverse methods panic, run over both the pair and single-block paths -- so + the claim is enforced rather than merely documented. +* **Parallel decryption**, via `encrypt_4blocks` / `encrypt_2blocks` (fours, then pairs, then a single block, like CBC): Sec 6.3 notes CFB decryption's forward cipher + calls "can be performed in parallel if the input blocks are first constructed (in series) from the + IV and the ciphertext", and with `s = b` those input blocks simply *are* the IV followed by the + ciphertext. Re-measured after the stream-cipher rewrite: against an otherwise identical + permutation that does not override the pair methods, this is **1.96x** the decryption throughput + (106.8 vs 54.6 MiB/s, AES-128, 16 KiB, N=8). In the same run CFB decryption was **1.26x** CBC + decryption (106.8 vs 84.9 MiB/s), because the bit-sliced engine's forward direction is cheaper + than its inverse and CFB only ever needs the forward one. CFB encryption is serial by + construction and does not use the pair path -- verified, not assumed: the swapped-pair test + permutation produces identical ciphertext under `Cfb` encrypt. +* **The byte path is close to free on encryption and modest on decryption.** Calls that are not a + whole number of blocks end mid-segment and the next call finishes that segment byte by byte. At + 125-byte calls (7 blocks and 13 bytes) encryption measured 51.1 MiB/s against 51.4 for + block-aligned calls, and decryption 90.6 against 106.8 -- the decrypt side pays because a partial + segment at each end of a call breaks the four-block batch. +* Verified against all six SP 800-38A **Appendix F.3.13-F.3.18** vectors (CFB128-AES128/192/256, + Encrypt and Decrypt) in the same four groupings as CBC. F.3 additionally tabulates the *output + blocks* -- the keystream -- so those are checked against the raw permutation too + (`Oj == CIPH_K(I_j)` and `Cj == Pj XOR Oj` for all four segments of all three key lengths), which + pins the mode's internals and not just its final output. As a transcription cross-check, CFB128 + is required to agree with **Appendix F.4.1 (OFB)** on the first block -- both compute + `C1 = P1 XOR CIPH_K(IV)` -- and to disagree from the second. +* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB128` AFT cases** from `bc-test-data` (all + three key lengths, both directions, 54 of them spanning 2-10 blocks), each run in four groupings: + block by block, in pairs with a remainder, as one call over the whole payload, and in 5-byte + calls that never line up with a block, so the byte path is exercised against real vectors with a + segment left open across calls. The 6 MCT groups are skipped and the count reported. These + vectors were already in `bc-test-data` and previously unused. +* Appendix D error propagation is tested in the direction that distinguishes CFB from CBC. Table D.2 + gives CFB "SBE in the decryption of Cj": every one of the 128 bit positions of `C2` is flipped and + required to flip *exactly* that bit of `P2` (the block the attacker aimed at, unlike CBC where it + lands in `P3`), to randomise `P3`, and to leave `P1` and `P4` untouched. The IV case is checked + with real AES, where a corrupted IV must *randomise* `P1` rather than flip a bit in place, and + must not affect any later block -- with `s = b`, Appendix D's "first `i/s` (rounding up)" + segments is one segment for every bit position. +* Mutation-tested: `cargo mutants -p bouncycastle-modes` reports **0 surviving mutants** across + the whole crate (220 mutants, 108 caught, 112 unviable, 0 missed, 0 timed out) -- 45 caught in + `ctr.rs`, 28 in `cfb.rs`, 16 in `cbc.rs`, 14 in `cfb8.rs`, 2 each in `ecb.rs` and `iv.rs` -- + including every `^`-to-`|`/`&` substitution and every keystream-stubbing mutant in the three + keystream modes. One mutant needed the tests to reach past runtime behaviour: stubbing out CTR's + compile-time counter-width guard cannot fail any runtime test, so the `compile_fail` doctests on + `Ctr` are what kill it. +* Still not implemented, and listed in the crate docs: **CFB1** (`s = 1`), whose segment is a + single bit rather than a whole number of bytes and so does not fit a byte-oriented API at all, + and **OFB** and **CTR**. + +CFB8 (`Cfb8`), SP 800-38A Sec 6.3 with `s = 8`: + +* **A different mode, not a variant.** `Cfb8` is its own type, because CFB8 and CFB128 are not + interoperable: they agree on the first byte of ciphertext -- `P1 XOR MSB_8(CIPH_K(IV))` in both -- + and diverge from the second, since `s = b` replaces the whole input block with the ciphertext + block while `s = 8` shifts one byte into a register. Both the type docs and the CLI help say so, + and a test asserts exactly that agree-then-diverge pattern rather than merely that the outputs + differ. +* **The shift register is the spec's own alternative description.** `I_{j+1} = LSB_{b-8}(Ij) | Cj` + is implemented as `rotate_left(1)` followed by writing the ciphertext byte into the last + position, which is Sec 6.3's "the bits of the first input block circularly shift s positions to + the left, and then the ciphertext segment replaces the s least significant bits of the result", + in that order. `MSB_8(Oj)` is the first byte of the output block; the other `b - 8` are + discarded, as Sec 6.3 requires. +* **A stream cipher with a one-byte segment**, so every byte string is a valid message: no + alignment rule, no padding, no partial-segment state. Same size as `Cbc` (192/224/256 B for + AES-128/192/256). +* **One forward cipher per byte.** Discarding 15 of every 16 output bytes is what the mode costs: + encryption measured **3.41 MiB/s** against CFB128's 51.4 on the same data and cipher, a factor of + 15. That is inherent to `s = 8`, and the crate docs, the type docs and the CLI help all say to + prefer `Cfb` unless a byte-granular self-synchronising stream is required or a format demands + CFB8. +* **Decryption still batches.** Sec 6.3's parallel decryption applies: the successive register + states depend only on the IV and the ciphertext, so they are built in series -- byte shuffling, + no cipher calls -- and the forward ciphers then run four at a time through `encrypt_4blocks`, + then in pairs. Measured **1.94x** the throughput of the same decryption in 1-byte calls, which + never batch (6.61 vs 3.40 MiB/s). Encryption cannot batch and does not. +* **Decryption never calls the inverse cipher**, as in CFB128, pinned by the same test permutation + whose inverse methods panic, run over the four-block, pair and single-byte paths. +* Verified against all six SP 800-38A **Appendix F.3.7-F.3.12** vectors (CFB8-AES128/192/256, + Encrypt and Decrypt), each in seven groupings from one byte per call up to the whole message. + F.3.7's tabulated **input and output blocks** -- all 18 of each -- are checked three ways: that + each input block is the previous one shifted with the ciphertext byte appended, that each output + block is `CIPH_K` of it through the raw permutation, and that `Cj == Pj XOR MSB_8(Oj)`. That pins + the register construction against the spec's own table rather than only the final ciphertext. +* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB8` AFT cases** from `bc-test-data` (all + three key lengths, both directions, 60 of them 16 to 160 bytes), each run in four groupings -- + whole message, byte by byte, 8-byte calls and 3-byte calls that never line up with the batch. + The 6 MCT groups are skipped and the count reported. These vectors were already in + `bc-test-data` and previously unused. +* Appendix D error propagation is checked in the form that distinguishes CFB8 from CFB128. Table + D.2 gives "SBE in the decryption of Cj" plus "RBE in ... Cj+1,...,Cj+b/s", and `b/s` is **16** + here rather than 1: with real AES, flipping a ciphertext bit flips exactly that bit of that + plaintext byte, randomises the following 16 bytes, and then decryption **resynchronises + exactly** -- byte `j + 17` onwards is required to be byte-identical to the original plaintext. + That self-synchronisation is the property CFB8 is chosen for, and the equality assertion on the + tail is what pins it. +* Interoperability checked byte for byte against OpenSSL's `EVP_aes_128_cfb8` on a 37-byte message, + in both directions. + +CTR (`Ctr`), SP 800-38A Sec 6.5: + +* **The nonce is the init data, and its length picks the counter width.** Sec 6.5 needs a sequence + of counter blocks that are distinct across every message under a key, and Appendix B.2's second + approach builds each one as a message nonce followed by a counter: "if N is the message nonce for + a given message, then the jth counter block is given by `Tj = N | [j]m`". `Ctr` takes that + literally, splitting the block by the length of its init data: the init data *is* the nonce, and + the remaining `BLOCK_LEN - INIT_DATA_LEN` bytes are the counter. The counter is capped at **4 + bytes** and must be at least 1, both checked at compile time, so on AES the nonce is 12, 13, 14 or + 15 bytes and a wrong one is a compile error rather than a runtime `Err`. +* **The counter starts at zero**, i.e. `Tj = N | [j - 1]m`, one below B.2's `[j]m`. Appendix B + presents B.2 as one of "Two examples of approaches" and closes by allowing "other methods and + approaches for achieving the uniqueness property", so both indexings satisfy the only normative + requirement, that the blocks be distinct. Zero is what makes a nonce-with-zero-counter vector line + up with an implementation handed the whole block as an IV -- which is how the ACVP vectors are + written, and how OpenSSL is driven. +* **Running out of counter is an error, and nothing is consumed.** A `CTR_LEN`-byte counter gives + `2^(8 * CTR_LEN)` blocks -- 64 GiB for a 4-byte counter, 4 KiB for a 1-byte one -- and Appendix + B.1 bounds a message at exactly that ("provided that `n <= 2^m`"). Past it the counter would + repeat, which for a keystream mode is keystream reuse *within one message*. `Ctr` therefore checks + the whole call up front and returns `SymmetricCipherError::StateError` without touching the data, + so a message is never half-encrypted before the mode notices. This is the first and only use in + the crate of the `Result` the data methods have always returned; CBC, CFB, CFB8 and ECB never fail + them. The counter is held as a `u64` rather than as the counter bytes precisely so that exhaustion + is representable: the counter field itself wraps. +* **Both directions are parallel**, the only mode here of which that is true. Sec 6.5: "In both CTR + encryption and CTR decryption, the forward cipher functions can be performed in parallel." + Counter blocks depend on nothing but the nonce and the index, so encryption batches through + `encrypt_4blocks` / `encrypt_2blocks` exactly as decryption does, and encryption and decryption are + the same operation. Only the forward cipher function is ever used, as in the CFB modes. +* The keystream block is the one buffer in this crate wrapped in `Secret`: a call may end part-way + through a block and the remainder is kept for the next one, and unlike a chaining value that + remainder is live key material for the bytes still to come. 224/256/288 B for AES-128/192/256 with + a 12-byte nonce. +* Verified against **1853 of the 2138 NIST ACVP `ACVP-AES-CTR` AFT cases** (all three key lengths, + both directions), each in four groupings. The other 285 begin at a non-zero counter and so cannot + be expressed through a nonce-plus-zero-counter API; they are skipped with the count reported. +* **Every ACVP case is a single block**, so none of them exercises the counter increment at all -- + a mode whose counter never advanced, or advanced little-endian, passes the entire set. (Checked, + not assumed: a deliberately little-endian counter was run against the ACVP suite while these tests + were written, and passed.) Two things close that gap. `ctr_vector_tests.rs` adds five-block + vectors for all three key lengths generated with **OpenSSL 3.0.13**, whose last block is partial + so they also pin Sec 6.5's `MSB_u(On)`; and `ctr_tests.rs` checks the counter blocks against the + raw permutation **at all four counter widths**, across the 255-to-256 carry where the width allows + it. That width sweep matters because the counter occupies a width-dependent slice, and getting it + wrong is invisible to a round-trip test: both directions would build the same wrong block and + still recover the plaintext. +* Cross-checked against **BC Java's `SICBlockCipher`**, which is the closest comparison available: + unlike OpenSSL, whose `-aes-*-ctr` takes the whole block as its IV and so has no notion of a + nonce, `SICBlockCipher` is built the same way -- a short IV goes in the leading bytes, the rest is + zero-filled so the counter starts at 0, it increments big-endian with carry, and it throws + `IllegalStateException("Counter in CTR/SIC mode out of range.")` once the carry would reach the + IV. Same construction, same start, same overflow rule; the only difference is that BC Java caps + the counter at `min(8, blockSize / 2)` bytes where this type stops at 4, so ours is a subset and + the two agree exactly on nonces of 12 to 15 bytes. Agreement is byte for byte on the 69-byte + vectors and on a 5000-byte message across the 255-to-256 carry at all three key lengths, and the + counter limit falls on the same byte at both the 1-byte (4 KiB) and 2-byte (1 MiB) widths. + `ctr_bc_java_tests.rs` pins what neither the ACVP nor the OpenSSL suite can reach: the keystream + at **1, 2 and 3-byte counters**, including both ends of the 1-byte counter's range and the + 2-byte counter's carry from block 255 to 256. +* SP 800-38A **Appendix F.5** is not transcribed: its vectors start the counter at `0xfcfdfeff` + rather than zero, so they cannot be expressed through this API. What F.5 does corroborate is the + split -- across its four blocks the counter moves only within the last four bytes, leaving the + leading twelve fixed -- and a test pins that reading. +* The counter limit is tested at two widths: a 1-byte counter (256 blocks, 4 KiB) and a 2-byte one + (65536 blocks, 1 MiB), in both directions, including that a refused call leaves the data and the + counter untouched so the bytes that do fit are unaffected by the attempt. + +`cli`: twelve new subcommands -- `aes{128,192,256}-cbc`, `-cfb`, `-cfb8` and `-ctr` -- each taking +`encrypt` or `decrypt` and streaming stdin to stdout in 1 KiB chunks. + +* The mode-independent plumbing lives once, in two halves that share their key loading and their + `encrypt` / `decrypt` spelling. `cli/src/block_mode_cmd.rs` holds the block half -- stdin framing + with block-alignment enforcement, hex/binary output -- generic over `BlockCipherEncryptor` / + `BlockCipherDecryptor`; `cli/src/stream_mode_cmd.rs` holds the stream half, generic over + `StreamCipherEncryptor` / `StreamCipherDecryptor`, which buffers nothing to a boundary and + rejects no length. `aes_cbc_cmd.rs`, `aes_ecb_cmd.rs`, `aes_cfb_cmd.rs` and `aes_cfb8_cmd.rs` are + thin dispatchers, so the commands cannot drift apart on the parts that affect correctness. +* Key from `--key` (hex) or `--key-file` (binary or hex), with the usual note that secrets on the + command line end up in shell history. The key length must match the variant exactly. +* **The IV travels in the ciphertext**: since there is no API for supplying one, `encrypt` writes + the generated IV as the first 16 bytes of its output and `decrypt` reads it back from the first + 16 bytes of its input, so `encrypt | decrypt` composes with no `--iv` flag anywhere. The IV need + not be secret (SP 800-38A Sec 5.3), so this is sound. +* Input to the `-cbc` and `-ecb` commands must be a whole number of 16-byte blocks; unaligned input + is rejected with a message saying the commands apply no padding rather than being silently + padded. The `-cfb` and `-cfb8` commands take **any length** and pad nothing, because they are + stream ciphers; their output is exactly as long as their input. +* The `-cfb` commands are **CFB128** and the `-cfb8` commands are **CFB8**, and every subcommand's + help names its segment size and says the two are not interoperable, because they would otherwise + silently produce incompatible output. +* The `-ctr` commands write a **12-byte nonce**, not the 16-byte IV every other mode writes, so + their output is 12 bytes longer than their input rather than 16. The per-command help says so, and + `cli/tests/aes_ctr_cli_tests.rs` (21 tests) pins it along with the OpenSSL vectors end to end, + CTR's total malleability (a flipped ciphertext bit flips exactly one plaintext bit and disturbs + nothing else), and that a CFB command cannot read a CTR ciphertext. +* Reads need not respect block boundaries: bytes accumulate in a 1 KiB buffer that goes through the flat + `do_*_out::<1024>` when full, and the whole-block remainder at end of input goes one block at a time; verified by + round-tripping 64 KiB through `dd bs=3`. +* Verified against SP 800-38A F.2 (CBC), F.3.13/F.3.15/F.3.17 (CFB128) and F.3.7/F.3.9/F.3.11 + (CFB8): prepending the spec's IV to the spec's ciphertext and running `decrypt` reproduces the + spec's plaintext for all three key lengths in every mode. The `encrypt` direction was + cross-checked against OpenSSL under the IV the CLI generated -- for CBC, and for both CFB modes + on a 37-byte (deliberately unaligned) message, where our ciphertext and `openssl enc + -aes-128-cfb` / `-aes-128-cfb8` agree byte for byte and each tool decrypts the other's output. +* `cli/tests/aes_cbc_cli_tests.rs` (16 tests) drives the built binary as a subprocess via + `CARGO_BIN_EXE_bc-rust`, so all of the above is asserted by `cargo test` rather than by hand: + the F.2 vectors, round trips across the chunk boundary, a fresh IV per invocation, hex/binary + agreement, `--key-file` in both hex and binary, and every error path with its message. +* `cli/tests/aes_cfb_cli_tests.rs` (21 tests) mirrors that suite -- the shared plumbing is generic + over the mode, so a wiring mistake in the CFB dispatcher would not show up in the CBC tests -- and + adds four CFB-specific checks: the F.3 vectors, the Appendix D single-bit malleability observed + end to end through the pipe, a guard that a CFB ciphertext does not decrypt as CBC or vice + versa (neither mode is authenticated, so the mismatch is otherwise silent), and that every length + from 0 to 33 bytes round-trips with the ciphertext exactly as long as the plaintext. +* `cli/tests/aes_cfb8_cli_tests.rs` (19 tests) does the same for CFB8, including the F.3.7/9/11 + vectors, every length from 0 to 33 bytes, and the Appendix D window: a flipped ciphertext bit + flips the same bit of the same plaintext byte, corrupts the next 16 bytes, and then the output is + required to be byte-identical to the original again. + +ECB (`Ecb`), SP 800-38A Sec 6.1: + +* **The raw permutation with the mode API, for interoperability only.** `Ecb` implements + `BlockCipherEncryptor` / `BlockCipherDecryptor` with `INIT_DATA_LEN = 0`: `do_encrypt_init` returns an empty array and + draws nothing from the RNG, `do_decrypt_init` takes one. Same direction typing, streaming and one-shot methods, + compile-time length checks and padding-layer composition as `Cbc` / `Cfb`, so a key-wrapping scheme, a legacy protocol + or a test-vector harness that needs ECB can use it through the same interface. The crate docs, the type docs and the + CLI help all say the same thing about it: **not a confidentiality mode for data** (Sec 6.1: "any given plaintext block + always gets encrypted to the same ciphertext block"). One block smaller than `Cbc` / `Cfb`, since nothing chains + (176 / 208 / 240 B for AES-128/192/256). +* **Both directions batch.** Sec 6.1 allows forward and inverse cipher calls "to be computed in parallel", so encryption + as well as decryption walks the blocks through `ElectronicCodeBook::{en,de}crypt_4blocks`, then the pair methods, then + a single block. The swapped-pair and rotated-four test permutations prove both paths are taken in both directions. +* `aes128-ecb` / `aes192-ecb` / `aes256-ecb` CLI subcommands over the shared block-mode plumbing, which is now generic + over `INIT_DATA_LEN`: nothing is prepended on `encrypt` or consumed on `decrypt`, so output is exactly as long as + input. The per-command help carries the warning. +* Verified against all six SP 800-38A **Appendix F.1** vectors (ECB-AES128/192/256, Encrypt and Decrypt) in five + groupings each -- and, since there is no IV, `encrypt` is checked against the published ciphertext too, through the + streaming API and the one-shot. Each tabulated ciphertext block is also checked to be `CIPH_K` of its plaintext block + through the raw permutation. The **NIST ACVP `ACVP-AES-ECB`** set (2138 AFT cases) already used by `aes` + is run again through the mode API, both directions, in three groupings including one that reaches the four-block + path. Structural tests pin the Sec 6.1 equations against a reference over the toy permutation, determinism and the + codebook property, Appendix D error propagation (a corrupted block randomises itself and nothing else, checked over + all 128 bit positions with real AES), the empty init data, and composition with `bouncycastle-padding`. + +`core`: new `ElectronicCodeBook` trait (`crypto/core/src/traits.rs`), the raw +keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. +`new`, `encrypt_block`, `decrypt_block`, plus provided `encrypt_2blocks` / `decrypt_2blocks` that +default to two single-block calls and `encrypt_4blocks` / `decrypt_4blocks` that default to two pair +calls, all of which bit-sliced implementations override (AES the pair form, SM4 both). The block methods +are infallible; only `new` can fail, and only on the key. `bouncycastle-aes` implements +it for all three key lengths (the data-encryption traits are still deliberately not implemented +there). + +`core`: new `SimpleCipherEncryptor` and +`SimpleCipherDecryptor` traits, the arbitrary-length data API a +caller uses, as opposed to the block-aligned `BlockCipher*` traits a mode implements. Their shape is +taken from `PaddedEncryptor` / `PaddedDecryptor`, which now implement them: streaming +`do_{en,de}crypt_init[_rng]`, exact `update_out_len`, `do_update_out`, and a consuming `do_final` that +returns the `FINAL_LEN` trailing buffer (the padded block; a tag for an AEAD) paired with how many of its +bytes are output -- always `FINAL_LEN` except for a padding scheme that adds nothing to aligned data -- +and, for the decryptor, how many of them are data. `do_final_out`, the `_out` one-shots +(`encrypt_out[_rng]`, `decrypt_out`, with `encrypt_out_len` exact and `decrypt_out_max_len` an upper +bound, checked before any work is done) and the `std` `Vec` one-shots are provided over the streaming +methods, so an implementor writes six methods. + +The older one-shot-only `SymmetricCipher` trait is **deleted**, and its four methods -- `encrypt`, +`encrypt_out`, `decrypt`, `decrypt_out` -- move onto `AEADCipher`, which was its only remaining +user. Every other kind of cipher now reaches an arbitrary-length one-shot some other way: a block +mode through `SimpleCipherEncryptor` / `SimpleCipherDecryptor` and the padding adapters, a +stream mode through those same traits directly. `AEADCipher` therefore drops the supertrait and +declares the four itself, against `NONCE_LEN`, with the documentation saying what they mean for an +AEAD: no additional authenticated data, and a ciphertext layout that is the implementation's +business because the tag has to go somewhere. `TestFrameworkSimpleCipher::test`, which was that +trait's suite, moves to `TestFrameworkAEADCipher::test_plain_one_shots` and is called from +`TestFrameworkAEADCipher::test`, so an AEAD implementor keeps the coverage without asking for it. + +That move also closed the last of a latent bug recorded in `core-test-framework/summary.md`: two +security-strength loops unwrapped `set_security_strength` at all five strengths, which a key shorter +than 32 bytes cannot carry, so they would have panicked for the first AEAD implementor — ASCON-128 +and AES-128-GCM among them. Relocating one of them into a method the AEAD suite calls would have +made that worse, so both now carry the same key-length guard the block and stream suites already +had. Every strength loop in the file is guarded. + +Stream ciphers also reach the arbitrary-length API: `StreamCipherEncryptor` and +`StreamCipherDecryptor` get blanket impls of `SimpleCipherEncryptor` / `SimpleCipherDecryptor` +with `FINAL_LEN = 0`, written in terms of the in-place `do_encrypt` / `do_decrypt`. An implementor +still writes only the in-place methods, but a caller can use `encrypt_out`, `do_update_out` and the +`std` one-shots, and can hold a stream mode through the same trait as a padded block mode -- which +is what makes "any of the five modes behind one trait" true rather than aspirational. For a stream +cipher the length predictions are exact rather than upper bounds, and `do_final` has nothing to +produce. The one cost is that both traits then spell `do_encrypt_init` identically, so code with +both in scope must qualify the call; `crypto/modes/tests/simple_cipher_api_tests.rs` is written +that way deliberately, to show it is workable. That file also runs all three stream modes through +`TestFrameworkSimpleCipher::test_encryptor_decryptor`, the same conformance suite the padded +adapters run, and checks the separate-output API against the in-place one byte for byte. + +Mutation-tested with `--test-workspace`, which is what these blanket impls need: run against core's +own tests alone they look untested, because core has no implementors of its own traits. Scoped to +the change, 45 mutants, 22 caught, 19 unviable, 4 missed -- all four the same equivalent mutant, +`[]` against `[0; 0]` and `[1; 0]` for a zero-length array, which no test can distinguish because +they are the same value; both sites carry a comment saying so. The one genuinely uncovered mutant +the run found, the decryptor's output-buffer length comparison, is now covered. + +`StreamCipher` is **replaced** by the split pair `StreamCipherEncryptor` / `StreamCipherDecryptor`, +shaped like `BlockCipherEncryptor` / `BlockCipherDecryptor` and for the same reasons: the direction +is encoded in the type, and a policy can permit decryption of an algorithm while forbidding new +encryptions. The old trait carried both directions and a `BLOCK_LEN` const parameter on every data +method, which a stream cipher has no use for; the new pair takes a `&mut [u8]` of any length, works +in place, generates its own init data in the constructor (never accepting one), and provides its +one-shots over a single implementor hook per direction. `Cfb` and `Cfb8` are its first implementors. + +Testing: + +* `core-test-framework` gains `TestFrameworkSimpleCipher::test_encryptor_decryptor`, which pins the + paired contract: one-shot round trips at every length up to a few final chunks, the `std` one-shots + against the `_out` ones, streaming in eight chunkings with `update_out_len` exact on every call, + `do_final_out` against `do_final`, a driven RNG reproducing its init data and determining the + ciphertext, corruption detection, short output buffers refused with the required length, and the + key-type and security-strength policy. The padded adapters run it. +* `core-test-framework` gains `TestFrameworkElectronicCodeBook`, which pins the trait contract: + both directions are inverses either way round, the permutation is injective, and the pair + methods are indistinguishable from two single-block calls **including their order** -- the check + that makes an override safe. +* Fixed a latent bug in `TestFrameworkBlockCipher`: it unwrapped `set_security_strength` at all + five strengths, which a key shorter than 32 bytes cannot carry, so the framework panicked for + any 16- or 24-byte key. It now skips the strengths the key length cannot hold. The bug was + invisible until now because nothing in the workspace implemented the block cipher traits. The + identical loop in `TestFrameworkSimpleCipher` and `TestFrameworkAEADCipher` got the same fix in + the same PR, and each also gained a `strengths_tested > 0` assertion so the sweep cannot silently + become vacuous again. `bouncycastle-ascon`'s `AsconAead128Encryptor`/`AsconAead128Decryptor` + (16-byte key) are now the first implementors to actually exercise the AEAD suite's guard. +* `TestFrameworkStreamCipher::test` was a `todo!()` and is now implemented for the + `StreamCipherEncryptor` / `StreamCipherDecryptor` pair, carrying the same key-length guard as the + block suite from the start. It pins the paired contract: one-shot round trips, streaming in nine + chunkings checked against the one-shot and against every other chunking (including empty calls, + so a call may end mid-segment), the RNG-taking constructors reproducing their init data and + determining the ciphertext, distinct init data across runs, the wrong key type rejected in both + directions, and the security-strength policy. `Cfb` and `Cfb8` both run it. + +* Block cipher padding (PR #97): + * padding -- new crate (`bouncycastle-padding`, no_std, re-exported as `bouncycastle::padding`) providing `PKCS7`, + the padding scheme of RFC 5652 s. 6.3, for any block length 1..=255 (enforced at compile time). `unpad` examines + every byte with `Condition` mask arithmetic and has a single public decision point, so it does not leak a + padding oracle through timing or error detail. + * `PaddedEncryptor` / `PaddedDecryptor` adapt a block-aligned `BlockCipherEncryptor` / + `BlockCipherDecryptor` to arbitrary-length data: streaming `do_update_out` / `do_final(self)` plus one-shot + `encrypt_out` / `decrypt_out`, with exact output-length helpers. The buffered partial plaintext block is held in + a `Secret`, and the decryptor withholds one complete block until `do_final`, since only the last block carries + padding. + * `core` gains the `Padding` trait (in-place `pad(block, data_len)`, constant-time + `unpad(block) -> data_len`, and `ALWAYS_PADS`, whether the scheme appends a block to already-aligned data) and + `PaddingError { DataLengthTooLong, InvalidPadding, PaddingNotPermitted }`, wrapped as a new variant of + `SymmetricCipherError`. + * `NoPadding`: the absence of padding as a `Padding` scheme, for data that must already be a whole number of + blocks. `pad` never writes a byte and returns `PaddingNotPermitted` whenever called; `unpad` reports the whole + block as data; `ALWAYS_PADS` is false. Through `PaddedEncryptor` / `PaddedDecryptor` this *enforces* alignment + with the arbitrary-length API shape: an aligned message passes through with its length unchanged and no final + block, an unaligned one fails at `do_final` / `encrypt_out`, and an empty ciphertext decrypts to the empty + message. The test framework's `TestFrameworkSimpleCipher` gained `required_alignment`, which makes it assert + that every unaligned length is refused. + * Tests are derived from the RFC 5652 padding rule; the adapters are driven with a toy XOR-CBC cipher implementing + the new block cipher traits, covering every data length, ten chunkings in both directions, tampering, malformed + lengths, and buffer sizing. Criterion bench included. + +`core`: new `AEADCipherEncryptor` and +`AEADCipherDecryptor` traits (#119/#120), the streaming API +for an authenticated cipher, shaped like `SimpleCipherEncryptor` / `SimpleCipherDecryptor` (separate +input/output buffers, exact `update_out_len`, generated nonce) with the two things authentication +adds: an AAD phase (`do_update_aad`, repeatable before the first `do_update_out`, refused with +`StateError` once data has started) and a finalizer that also produces the tag +(`do_encrypt_final`/`do_decrypt_final`, flushing up to `FINAL_LEN` held-back bytes alongside it). +`FINAL_LEN` is `0` for a cipher like Ascon-AEAD128 that never buffers; a block-oriented AEAD or one +whose wire format inlines the tag would need it non-zero. The one-shots (`encrypt_out[_rng]`, +`decrypt_out`, and the `std` `Vec` forms) are provided over the streaming methods, so an implementor +writes seven. `bouncycastle-ascon`'s `AsconAead128Encryptor` / `AsconAead128Decryptor` are the first +implementors. + +Mutation-tested with `cargo mutants -p bouncycastle-core -F 'AEADCipher(Encryptor|Decryptor)' +--test-package bouncycastle-ascon` (`core` has no implementor of its own to test against): 68 +mutants, 49 caught, 10 unviable, 9 missed -- all nine equivalent given `FINAL_LEN = 0`, the only +value Ascon-AEAD128 exercises. Six are `written + final_len` vs `written - final_len` in +`encrypt_out`/`encrypt_out_rng`/`decrypt_out`'s final-buffer splice, indistinguishable because +`final_len` is always `0` there; the other three are the one-shots' own buffer-length guard +(`plaintext.len() < needed` / `ciphertext.len() < needed`) against `>`, indistinguishable because +`needed` at `FINAL_LEN = 0` is exactly the bound Ascon's own `do_update_out` already enforces one +call deeper, so the outer guard's direction is never the only thing standing between a short buffer +and an error. A future `FINAL_LEN > 0` implementor (a block-oriented AEAD) would give both classes +of mutant something to bite on. + +Where the tag goes is deliberately not fixed by the pair (contrast `AEADCipher`, whose one-shots +pick a layout): `core::tagged_aead::TaggedEncryptor` / `TaggedDecryptor` adapt any +`FINAL_LEN = 0` implementor to `SimpleCipherEncryptor` / `SimpleCipherDecryptor`, producing and +consuming the inline `ciphertext || tag` layout most wire formats and files use, with the AAD phase +still reachable through an inherent `do_update_aad` the `SimpleCipher*` traits have no slot for. +`TaggedDecryptor` holds back exactly the last `TAG_LEN` bytes it has seen at any point, releasing +everything older through the wrapped decryptor as soon as it is known not to be the tag -- the same +technique `bc-rust`'s `ascon-aead128 --decrypt` used by hand before this adapter existed, now +provided once. (A fully general adapter over a implementor whose own `FINAL_LEN` is non-zero needs +this adapter's `FINAL_LEN` to be `INNER_FINAL_LEN + TAG_LEN`, a value derived from two other const +generics that stable const generics cannot express as a trait argument; left to a future adapter.) + +New crate `bouncycastle-ascon` (`bouncycastle::ascon`): Ascon-AEAD128 / Ascon-Hash256 / Ascon-XOF128 +/ Ascon-CXOF128 (NIST SP 800-232), the lightweight cryptography suite selected from the NIST +Lightweight Cryptography competition. + +* `AsconAead128` is the streaming primitive (rate 128 bits, capacity 192 bits, `Ascon-p[12]` at + init/finalization and `Ascon-p[8]` on AAD/data blocks), with a caller-supplied nonce for KAT and + protocol use. Every plaintext/ciphertext byte is transformed and emitted the moment it is seen -- + no held-back buffering across calls -- because within a rate block each byte is independent of + the others in it; this is what lets its finalizers have nothing left to flush. + `AsconAead128Encryptor` / `AsconAead128Decryptor` are thin newtypes over it implementing the new + `AEADCipherEncryptor` / `AEADCipherDecryptor` pair with an internally-generated nonce; `AsconAead128` + itself keeps implementing the one-shot-only `AEADCipher` (both directions on one type, chosen by a + runtime flag), which the newtype split cannot replace since that trait needs both directions + available on a single implementor. +* `AsconHash256` (`Hash`) and `AsconXof128` (`XOF`) are sponge constructions over the same + permutation; `AsconCXof128` (`XOF`) adds the customization string of SP 800-232 Algorithm 7 (up to + 256 bytes). All four are byte-oriented: `do_final_partial_bits`/the equivalent XOF methods always + return an error rather than accept a partial final byte, unlike SHA-2/SHA-3. Registered in + `HashFactory` (`"Ascon-Hash256"`) and `XOFFactory` (`"Ascon-XOF128"`), with `ascon-hash256`, + `ascon-xof128`, `ascon-cxof128` and `ascon-aead128` CLI subcommands; the last streams both + directions in 1 KiB chunks, decrypting through `TaggedDecryptor` rather than a hand-rolled tail + buffer. +* **Decryption releases plaintext before the tag is checked**, streaming or through the CLI: bytes + are necessarily written to the caller's buffer (or stdout) before the last `TAG_LEN` bytes -- the + tag -- can be read and compared. A non-zero exit from the CLI, or an `Err` from the streaming + finalizer, means the input was tampered with and any output already produced must be discarded; + do not treat it as authentic before that point. The one-shot APIs (`AsconAead128::decrypt`, both + `AEADCipher` and `AEADCipherDecryptor` views) do not have this caveat: they own the whole message + and zeroize the output buffer before returning an error. +* Verified against 4228 NIST LWC KAT vectors from `bc-test-data` (1089 each for AEAD128 and + CXOF128, 1025 each for Hash256 and XOF128), plus embedded always-on vectors for when that + repository is not checked out. Mutation-tested with `cargo mutants -p bouncycastle-ascon`: 665 + mutants, 558 caught, 103 unviable, 4 missed -- all four the same equivalent survivors as the + crate's introduction (PR #21): the `Sponge::absorb`/`squeeze` boundary pair and the disjoint-bit + `set_state_byte` OR-vs-XOR pair, neither touched by the `AEADCipherEncryptor`/`AEADCipherDecryptor` + work. ## Minor features / bug fixes diff --git a/cli/src/ascon_cmd.rs b/cli/src/ascon_cmd.rs new file mode 100644 index 00000000..49ca5297 --- /dev/null +++ b/cli/src/ascon_cmd.rs @@ -0,0 +1,194 @@ +use std::io::{self, Read}; +use std::process::exit; + +use bouncycastle::ascon::ascon_aead128::{AsconAead128, AsconAead128Decryptor}; +use bouncycastle::ascon::ascon_cxof128::AsconCXof128; +use bouncycastle::ascon::ascon_hash256::AsconHash256; +use bouncycastle::ascon::ascon_xof128::AsconXof128; +use bouncycastle::core::errors::SymmetricCipherError; +use bouncycastle::core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle::core::tagged_aead::TaggedDecryptor; +use bouncycastle::core::traits::{SecurityStrength, SimpleCipherDecryptor}; +use bouncycastle::hex; + +use crate::helpers; + +/// Load a hex string or a binary/hex file into bytes; exits with an error if neither is supplied. +fn load_bytes(value: &Option, value_file: &Option, label: &str) -> Vec { + if let Some(file) = value_file { + helpers::read_from_file(file) + } else if let Some(v) = value { + hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: {label} is not valid hex."); + exit(-1) + }) + } else { + eprintln!("Error: {label} must be supplied."); + exit(-1) + } +} + +fn require_16(bytes: Vec, label: &str) -> [u8; 16] { + bytes.try_into().unwrap_or_else(|_: Vec| { + eprintln!("Error: {label} must be exactly 16 bytes."); + exit(-1) + }) +} + +/// Build a `KeyMaterial<16>` for the AEAD key, warning (and forcing usable metadata) only if the +/// key turns out to be low-entropy (e.g. all-zero), the same way `helpers::parse_seed` does. +fn load_key_material(key_bytes: &[u8; 16]) -> KeyMaterial<16> { + let mut key = + KeyMaterial::<16>::from_bytes_as_type(key_bytes, KeyType::SymmetricCipherKey).unwrap(); + if key.key_type() == KeyType::Zeroized || key.security_strength() < SecurityStrength::_128bit { + eprintln!( + "Warning: low entropy key provided. We'll still process it, but it may be insecure." + ); + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::_128bit) + }) + .unwrap(); + } + key +} + +/// Ascon-Hash256 of stdin. Streaming update; 256-bit digest. +pub(crate) fn hash256_cmd(output_hex: bool) { + helpers::stream_hash(AsconHash256::new(), output_hex); +} + +/// Ascon-XOF128 of stdin, producing `output_len` bytes. Streaming absorb. +pub(crate) fn xof128_cmd(output_len: usize, output_hex: bool) { + helpers::stream_xof(AsconXof128::new(), output_len, output_hex); +} + +/// Ascon-CXOF128 of stdin with a hex customization string, producing `output_len` bytes. +pub(crate) fn cxof128_cmd(customization: &Option, output_len: usize, output_hex: bool) { + let z = match customization { + Some(v) => hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: customization is not valid hex."); + exit(-1) + }), + None => Vec::new(), + }; + let x = AsconCXof128::with_customization(&z).unwrap_or_else(|_| { + eprintln!("Error: customization string exceeds 256 bytes."); + exit(-1) + }); + helpers::stream_xof(x, output_len, output_hex); +} + +/// Ascon-AEAD128 of stdin. Encrypts (stdin = plaintext, output = ciphertext||tag) or, with +/// `decrypt`, decrypts (stdin = ciphertext||tag, output = plaintext). Decryption exits with a +/// non-zero status if the authentication tag does not verify. +/// +/// Both directions stream stdin in fixed-size chunks (no full-buffer slurp). Encryption emits +/// ciphertext eagerly, before the tag is known; note that in the decryption direction, plaintext +/// is likewise emitted before the tag has been checked, so it should not be treated as +/// authentic until this command exits with status 0 (see the crate's "Security Considerations"). +pub(crate) fn aead128_cmd( + key: &Option, + key_file: &Option, + nonce: &Option, + nonce_file: &Option, + ad: &Option, + decrypt: bool, + output_hex: bool, +) { + let key = load_key_material(&require_16(load_bytes(key, key_file, "key"), "key")); + let nonce = require_16(load_bytes(nonce, nonce_file, "nonce"), "nonce"); + let ad_bytes = match ad { + Some(v) => hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: associated data is not valid hex."); + exit(-1) + }), + None => Vec::new(), + }; + let ad_opt = if ad_bytes.is_empty() { None } else { Some(ad_bytes.as_slice()) }; + + if decrypt { + aead128_decrypt_stream(&key, &nonce, ad_opt, output_hex); + } else { + aead128_encrypt_stream(&key, &nonce, ad_opt, output_hex); + } +} + +fn aead128_encrypt_stream( + key: &KeyMaterial<16>, + nonce: &[u8; 16], + ad_opt: Option<&[u8]>, + output_hex: bool, +) { + let mut cipher = AsconAead128::new(key, nonce, ad_opt, true).unwrap(); + let mut buf = [0u8; 1024]; + loop { + let n = io::stdin().read(&mut buf).expect("Failed to read from stdin"); + if n == 0 { + break; + } + cipher.do_encrypt_update(&mut buf[..n]); + helpers::write_bytes_or_hex(&buf[..n], output_hex); + } + let tag = cipher.do_encrypt_final(); + helpers::write_bytes_or_hex(&tag, output_hex); + if output_hex { + println!(); + } +} + +/// Decrypts a stream whose final 16 bytes are the tag, which is only known once EOF is reached. +/// The tag-candidate hold-back this needs is [`TaggedDecryptor`]'s job, not this function's: it +/// adapts [`AsconAead128Decryptor`] to the `ciphertext || tag` layout, releasing everything but +/// the last 16 bytes it has seen as soon as it is known not to be the tag. +fn aead128_decrypt_stream( + key: &KeyMaterial<16>, + nonce: &[u8; 16], + ad_opt: Option<&[u8]>, + output_hex: bool, +) { + const CHUNK: usize = 1024; + + let mut cipher = as SimpleCipherDecryptor< + 16, + 16, + 16, + >>::do_decrypt_init(key, nonce) + .unwrap(); + if let Some(ad) = ad_opt { + cipher.do_update_aad::<16, 16>(ad).unwrap(); + } + + let mut buf = [0u8; CHUNK]; + loop { + let n = io::stdin().read(&mut buf).expect("Failed to read from stdin"); + if n == 0 { + break; + } + let expect = cipher.update_out_len(n); + let mut out = vec![0u8; expect]; + // infallible: `out` is sized exactly to `update_out_len`, the only length + // `IncorrectOutputBufferLength` could complain about. + let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); + helpers::write_bytes_or_hex(&out[..written], output_hex); + } + + match cipher.do_final() { + Ok((last, last_len)) => { + helpers::write_bytes_or_hex(&last[..last_len], output_hex); + if output_hex { + println!(); + } + } + Err(SymmetricCipherError::DecryptionFailed) => { + eprintln!("Error: ciphertext is shorter than the 16-byte tag."); + exit(-1); + } + Err(_) => { + eprintln!("Error: Ascon-AEAD128 authentication failed."); + exit(-1); + } + } +} diff --git a/cli/src/helpers.rs b/cli/src/helpers.rs index 207f0ee0..2873e1e6 100644 --- a/cli/src/helpers.rs +++ b/cli/src/helpers.rs @@ -1,7 +1,7 @@ use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::traits::SecurityStrength; +use bouncycastle::core::traits::{Hash, SecurityStrength, XOF}; use bouncycastle::hex; use std::fs::File; use std::io; @@ -116,3 +116,35 @@ pub(crate) fn parse_seed(bytes: &[u8]) -> Result, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// Ascon-AEAD128 authenticated encryption/decryption of the content provided on stdin. + /// Encrypts by default (stdin = plaintext, output = ciphertext||tag); with --decrypt the + /// reverse. Decryption fails with a non-zero exit status if the tag does not verify. + /// Note: in production uses, secrets should not be passed on the command-line because they get + /// logged in shell history. Use the file-based input instead. + /// Security note: decryption streams its output, so plaintext bytes are written to stdout + /// before the authentication tag (the last 16 bytes of input) can be checked. Do not treat + /// the output as authentic until this command exits with status 0; a non-zero exit means the + /// input was tampered with and any plaintext already written must be discarded. + AsconAEAD128 { + /// The 128-bit key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 128-bit key in hex or binary. + #[arg(long)] + key_file: Option, + + /// The 128-bit nonce in hex. Must be unique per encryption under a given key. + #[arg(long)] + nonce: Option, + + /// A file containing the 128-bit nonce in hex or binary. + #[arg(long)] + nonce_file: Option, + + /// Associated data in hex (authenticated but not encrypted). + #[arg(long)] + ad: Option, + + /// Decrypt instead of encrypt. + #[arg(short, long)] + decrypt: bool, + + #[arg(short)] + /// Output in hex format. x: bool, }, @@ -1248,6 +1320,17 @@ fn main() { } Some(Subcommands::CSHAKE256 { length, customization, function_name, x }) => { sha3_cmd::cshake_cmd(256, *length, function_name, customization, *x); + Some(Subcommands::AsconHash256 { x }) => { + ascon_cmd::hash256_cmd(*x); + } + Some(Subcommands::AsconXOF128 { length, x }) => { + ascon_cmd::xof128_cmd(*length, *x); + } + Some(Subcommands::AsconCXOF128 { length, customization, x }) => { + ascon_cmd::cxof128_cmd(customization, *length, *x); + } + Some(Subcommands::AsconAEAD128 { key, key_file, nonce, nonce_file, ad, decrypt, x }) => { + ascon_cmd::aead128_cmd(key, key_file, nonce, nonce_file, ad, *decrypt, *x); } Some(Subcommands::HMAC_SHA256 { key, key_file, verify, x }) => { mac_cmd::mac_cmd(HMACVariant::SHA256, key, key_file, verify, *x) diff --git a/cli/src/sha3_cmd.rs b/cli/src/sha3_cmd.rs index 7c0ae4c6..d7a8d7fc 100644 --- a/cli/src/sha3_cmd.rs +++ b/cli/src/sha3_cmd.rs @@ -9,42 +9,22 @@ use bouncycastle::sha3::{ }; use std::process::exit; +use crate::helpers::{stream_hash, stream_xof}; + pub(crate) fn sha3_cmd(bit_len: usize, output_hex: bool) { match bit_len { - 224 => do_sha3(SHA3_224::new(), output_hex), - 256 => do_sha3(SHA3_256::new(), output_hex), - 384 => do_sha3(SHA3_384::new(), output_hex), - 512 => do_sha3(SHA3_512::new(), output_hex), + 224 => stream_hash(SHA3_224::new(), output_hex), + 256 => stream_hash(SHA3_256::new(), output_hex), + 384 => stream_hash(SHA3_384::new(), output_hex), + 512 => stream_hash(SHA3_512::new(), output_hex), _ => panic!("Unsupported algorithm: SHA3-{}", bit_len), } } -fn do_sha3(mut sha3: impl Hash, output_hex: bool) { - let mut buf: [u8; 1024] = [0u8; 1024]; - - // read from stdin - let mut bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin"); - while bytes_read != 0 { - sha3.do_update(&buf[..bytes_read]); - bytes_read = io::stdin().read(&mut buf).expect("Failed to read from stdin"); - } - - let out = sha3.do_final(); - - if output_hex { - for b in out.iter() { - print!("{b:02x}"); - } - } else { - io::stdout().write(&out).unwrap(); - } - println!(); -} - pub(crate) fn shake_cmd(bit_len: usize, output_len: usize, output_hex: bool) { match bit_len { - 128 => do_shake(SHAKE128::new(), output_len, output_hex), - 256 => do_shake(SHAKE256::new(), output_len, output_hex), + 128 => stream_xof(SHAKE128::new(), output_len, output_hex), + 256 => stream_xof(SHAKE256::new(), output_len, output_hex), _ => panic!("Unsupported algorithm: SHAKE-{}", bit_len), } } diff --git a/cli/tests/ascon_cli_tests.rs b/cli/tests/ascon_cli_tests.rs new file mode 100644 index 00000000..3cf3c6de --- /dev/null +++ b/cli/tests/ascon_cli_tests.rs @@ -0,0 +1,308 @@ +//! Tests for the `ascon-hash256` / `ascon-xof128` / `ascon-cxof128` / `ascon-aead128` +//! subcommands. +//! +//! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is +//! the command-line contract itself -- KAT-level correctness through the pipe, the `ciphertext || +//! tag` layout, `--key-file`/`--nonce-file` loading, AAD, and exit codes -- none of which is +//! reachable from the library API, which `crypto/ascon/tests/*.rs` already covers directly. +//! +//! The KAT values below are taken from the embedded vectors already pinned in +//! `crypto/ascon/tests/{hash256,xof128,cxof128,aead128}_tests.rs` (themselves NIST LWC vectors), +//! not retyped from memory. +//! +//! `CARGO_BIN_EXE_bc-rust` is set by cargo for integration tests and points at the binary for the +//! current profile, so there is nothing to build or locate by hand. + +use std::io::{ErrorKind, Write}; +use std::process::{Command, Output, Stdio}; +use std::thread; + +/// The path to the binary under test, resolved by cargo. +const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust"); + +/// The NIST LWC AEAD KAT convention uses key == nonce for the embedded vectors (see +/// `crypto/ascon/tests/aead128_tests.rs`'s `aead128_embedded_kat`). +const KEY_HEX: &str = "000102030405060708090a0b0c0d0e0f"; + +/// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output. +/// +/// See `aes_ctr_cli_tests.rs::run` for why stdin is written from a separate thread (a pipe with a +/// bounded buffer deadlocks otherwise) and why a `BrokenPipe` write error is swallowed (an +/// error-path command may exit before draining stdin). +fn run(args: &[&str], stdin_bytes: &[u8]) -> Output { + let mut child = Command::new(BC_RUST) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn bc-rust"); + + let mut stdin = child.stdin.take().expect("stdin piped"); + let payload = stdin_bytes.to_vec(); + let writer = thread::spawn(move || { + match stdin.write_all(&payload) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::BrokenPipe => {} + Err(e) => panic!("failed to write to stdin: {e}"), + } + // `stdin` drops here, closing the pipe so the child sees EOF and can exit. + }); + + let output = child.wait_with_output().expect("failed to wait for bc-rust"); + writer.join().expect("the stdin writer thread panicked"); + output +} + +/// Runs a command that is expected to succeed, returning stdout. +fn run_ok(args: &[&str], stdin_bytes: &[u8]) -> Vec { + let out = run(args, stdin_bytes); + assert!( + out.status.success(), + "expected success from {args:?}, got {:?}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + out.stdout +} + +/// Runs a command that is expected to fail, returning stderr as a string. +fn run_err(args: &[&str], stdin_bytes: &[u8]) -> String { + let out = run(args, stdin_bytes); + assert!( + !out.status.success(), + "expected failure from {args:?}, but it succeeded\nstdout: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn unhex(s: &str) -> Vec { + assert!(s.len().is_multiple_of(2), "hex string must have even length"); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() +} + +/// Deterministic pseudo-random bytes, so the tests do not depend on an RNG or on `/dev/urandom`. +fn pseudo_random(len: usize, seed: u32) -> Vec { + let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state >> 24) as u8 + }) + .collect() +} + +fn hex_stdout(args: &[&str], stdin_bytes: &[u8]) -> String { + let out = run_ok(args, stdin_bytes); + String::from_utf8(out).expect("hex output is text").trim_end().to_string() +} + +// ---- ascon-hash256 ------------------------------------------------------------------------ + +/// LWC_HASH_KAT_256.txt Count 1: the digest of the empty message. +#[test] +fn ascon_hash256_matches_the_embedded_kat_for_the_empty_message() { + let out = hex_stdout(&["ascon-hash256", "-x"], &[]); + assert_eq!(out, "0b3be5850f2f6b98caf29f8fdea89b64a1fa70aa249b8f839bd53baa304d92b2"); +} + +/// A non-empty message, matching LWC_HASH_KAT_256.txt Count 9. +#[test] +fn ascon_hash256_matches_the_embedded_kat_for_a_multi_byte_message() { + let out = hex_stdout(&["ascon-hash256", "-x"], &unhex("0001020304050607")); + assert_eq!(out, "b88e497ae8e6fb641b87ef622eb8f2fca0ed95383f7ffebe167acf1099ba764f"); +} + +// ---- ascon-xof128 -------------------------------------------------------------------------- + +/// LWC_XOF_KAT_128_512.txt Count 1: 64 bytes squeezed after absorbing the empty message. +#[test] +fn ascon_xof128_matches_the_embedded_kat_for_the_empty_message() { + let out = hex_stdout(&["ascon-xof128", "64", "-x"], &[]); + assert_eq!( + out, + "473d5e6164f58b39dfd84aacdb8ae42ec2d91fed33388ee0d960d9b3993295c\ + 6ad77855a5d3b13fe6ad9e6098988373af7d0956d05a8f1665d2c67d1a3ad10ff" + ); +} + +/// The output length is the caller's choice, and shorter output is a prefix of longer output +/// (every XOF's defining property) -- pinned here through the CLI specifically, since the CLI is +/// what turns the length into a positional argument. +#[test] +fn ascon_xof128_output_length_is_a_prefix_of_a_longer_squeeze() { + let full = hex_stdout(&["ascon-xof128", "64", "-x"], &[]); + let short = hex_stdout(&["ascon-xof128", "16", "-x"], &[]); + assert_eq!(short.len(), 32, "16 bytes is 32 hex characters"); + assert!(full.starts_with(&short)); +} + +// ---- ascon-cxof128 ------------------------------------------------------------------------- + +/// LWC_CXOF_KAT_128_512.txt Count 4: message `00`, customization `10`. +#[test] +fn ascon_cxof128_matches_the_embedded_kat() { + let out = hex_stdout(&["ascon-cxof128", "64", "--customization", "10", "-x"], &unhex("00")); + assert_eq!( + out, + "63fa8ba86382f2d544580f51322d080424b42c556eb74503cd73cf052bb993\ + bd6f5210984c71c9c445f43ccc5b158226e509bd339cd634414377f79411aa8d5c" + ); +} + +/// No `--customization` at all must give the same output as an empty one: `AsconCXof128::new()` +/// versus `with_customization(&[])`, both reachable only through the library elsewhere -- here we +/// pin that the CLI's `Option` plumbing treats "absent" and "empty" identically. +#[test] +fn ascon_cxof128_with_no_customization_matches_an_empty_one() { + let without = hex_stdout(&["ascon-cxof128", "64", "-x"], &[]); + let with_empty = hex_stdout(&["ascon-cxof128", "64", "--customization", "", "-x"], &[]); + assert_eq!(without, with_empty); + // LWC_CXOF_KAT_128_512.txt Count 1: message and customization both empty. + assert_eq!( + without, + "4f50159ef70bb3dad8807e034eaebd44c4fa2cbbc8cf1f05511ab66cdcc5299\ + 05ca12083fc186ad899b270b1473dc5f7ec88d1052082dcdfe69fb75d269e7b74" + ); +} + +// ---- ascon-aead128 ------------------------------------------------------------------------- + +/// LWC_AEAD_KAT_128_128.txt Count 1: the tag over an empty message with no AAD (key == nonce). +#[test] +fn ascon_aead128_matches_the_embedded_kat_for_an_empty_message() { + let out = hex_stdout(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "-x"], &[]); + assert_eq!(out, "4427d64b8e1e1451fc445960f0839bb0"); +} + +/// Encrypt then `--decrypt` round-trips a multi-KB payload, byte for byte, and the ciphertext is +/// exactly the plaintext plus the 16-byte tag. +#[test] +fn ascon_aead128_encrypt_then_decrypt_round_trips() { + let plaintext = pseudo_random(4096, 0xC0FFEE); + let ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); + assert_eq!(ciphertext.len(), plaintext.len() + 16, "ciphertext is plaintext plus the tag"); + + let recovered = + run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + assert_eq!(recovered, plaintext); +} + +/// Associated data is authenticated on both sides of a round trip. +#[test] +fn ascon_aead128_associated_data_round_trips() { + let plaintext = pseudo_random(256, 7); + let ciphertext = run_ok( + &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef"], + &plaintext, + ); + let recovered = run_ok( + &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef", "--decrypt"], + &ciphertext, + ); + assert_eq!(recovered, plaintext); +} + +/// Decrypting with the wrong associated data must fail the tag check, the same as tampering with +/// the ciphertext itself. +#[test] +fn ascon_aead128_wrong_associated_data_is_rejected() { + let plaintext = pseudo_random(64, 11); + let ciphertext = run_ok( + &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef"], + &plaintext, + ); + let stderr = run_err( + &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "cafebabe", "--decrypt"], + &ciphertext, + ); + assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); +} + +/// A single flipped ciphertext byte must fail the tag check on decrypt, with a non-zero exit and +/// an explanatory stderr message -- the security-relevant contract the streaming decrypt path +/// (`ascon_cmd.rs::aead128_decrypt_stream`) exists to uphold. +#[test] +fn ascon_aead128_a_flipped_ciphertext_byte_is_rejected() { + let plaintext = pseudo_random(64, 1); + let mut ciphertext = + run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); + ciphertext[0] ^= 0x01; + + let stderr = + run_err(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); +} + +/// A flipped tag byte (the last byte of the stream) must be rejected the same way. +#[test] +fn ascon_aead128_a_flipped_tag_byte_is_rejected() { + let plaintext = pseudo_random(64, 2); + let mut ciphertext = + run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); + let last = ciphertext.len() - 1; + ciphertext[last] ^= 0x01; + + let stderr = + run_err(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); +} + +/// Decrypt input shorter than the 16-byte tag is rejected before any tag check is attempted, +/// including the empty-input case. +#[test] +fn ascon_aead128_decrypt_input_shorter_than_the_tag_is_rejected() { + for len in [0usize, 1, 15] { + let stderr = run_err( + &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], + &pseudo_random(len, len as u32 + 1), + ); + assert!( + stderr.contains("shorter than the 16-byte tag"), + "len {len}: stderr should explain the missing tag: {stderr}" + ); + } +} + +/// `--key-file`/`--nonce-file` accept binary content, not just hex, the same as the AES commands' +/// `--key-file` (see `key_file_accepts_hex_and_binary` in `aes_ctr_cli_tests.rs`). +#[test] +fn ascon_aead128_key_file_and_nonce_file_accept_binary_content() { + let dir = std::env::temp_dir().join(format!("ascon_cli_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let key_path = dir.join("key.bin"); + let nonce_path = dir.join("nonce.bin"); + std::fs::write(&key_path, unhex(KEY_HEX)).expect("write key file"); + std::fs::write(&nonce_path, unhex(KEY_HEX)).expect("write nonce file"); + + let out = hex_stdout( + &[ + "ascon-aead128", + "--key-file", + key_path.to_str().unwrap(), + "--nonce-file", + nonce_path.to_str().unwrap(), + "-x", + ], + &[], + ); + assert_eq!(out, "4427d64b8e1e1451fc445960f0839bb0"); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// The subcommands are listed in top-level help. +#[test] +fn the_subcommands_are_listed_in_help() { + let out = run_ok(&["--help"], &[]); + let text = String::from_utf8_lossy(&out); + for name in ["ascon-hash256", "ascon-xof128", "ascon-cxof128", "ascon-aead128"] { + assert!(text.contains(name), "--help should list {name}"); + } +} diff --git a/crypto/ascon/Cargo.toml b/crypto/ascon/Cargo.toml new file mode 100644 index 00000000..25a58829 --- /dev/null +++ b/crypto/ascon/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bouncycastle-ascon" +version.workspace = true +edition.workspace = true + +[features] +# `std` gates the ergonomic, allocating (`Vec`-returning) one-shot cipher APIs, mirroring the +# `std` feature of `bouncycastle-core`. On by default; a future `--no-default-features` build is +# what will let the crate move toward `#![no_std]`. +default = ["std"] +std = ["bouncycastle-core/std"] + +[dependencies] +bouncycastle-core.workspace = true +bouncycastle-rng.workspace = true +bouncycastle-utils.workspace = true + +[dev-dependencies] +bouncycastle-core-test-framework.workspace = true +bouncycastle-hex.workspace = true +criterion.workspace = true + +[[bench]] +name = "ascon_benches" +harness = false diff --git a/crypto/ascon/benches/ascon_benches.rs b/crypto/ascon/benches/ascon_benches.rs new file mode 100644 index 00000000..eebe3f17 --- /dev/null +++ b/crypto/ascon/benches/ascon_benches.rs @@ -0,0 +1,93 @@ +use bouncycastle_rng as rng; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; + +use bouncycastle_ascon::ascon_aead128::AsconAead128; +use bouncycastle_ascon::ascon_cxof128::AsconCXof128; +use bouncycastle_ascon::ascon_hash256::AsconHash256; +use bouncycastle_ascon::ascon_xof128::AsconXof128; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{Hash, RNG, XOF}; + +const DATA_LEN: usize = 16 * 1024; + +fn random_data(len: usize) -> Vec { + let mut data = vec![0u8; len]; + rng::DefaultRNG::default().next_bytes_out(&mut data).unwrap(); + data +} + +fn bench_aead128_encrypt(c: &mut Criterion) { + let key = + KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); + let nonce = [0x24u8; 16]; + let data = random_data(DATA_LEN); + let mut out = vec![0u8; DATA_LEN + 16]; + + let mut group = c.benchmark_group("ascon::AsconAead128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + group.bench_function(format!("{DATA_LEN} bytes -- ::encrypt()"), |b| { + b.iter(|| { + AsconAead128::encrypt(&key, &nonce, None, black_box(&data), &mut out).unwrap(); + black_box(&out); + }) + }); + group.finish(); +} + +fn bench_hash256(c: &mut Criterion) { + let data = random_data(DATA_LEN); + let mut digest = [0u8; 32]; + + let mut group = c.benchmark_group("ascon::AsconHash256"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + group.bench_function(format!("{DATA_LEN} bytes -- ::hash_out()"), |b| { + b.iter(|| { + AsconHash256::new().hash_out(black_box(&data), &mut digest); + black_box(&digest); + }) + }); + group.finish(); +} + +fn bench_xof128(c: &mut Criterion) { + let data = random_data(DATA_LEN); + let mut out = [0u8; 64]; + + let mut group = c.benchmark_group("ascon::AsconXof128"); + group.throughput(Throughput::Bytes((DATA_LEN + out.len()) as u64)); + group.bench_function( + format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::hash_xof_out()"), + |b| { + b.iter(|| { + AsconXof128::new().hash_xof_out(black_box(&data), &mut out); + black_box(&out); + }) + }, + ); + group.finish(); +} + +fn bench_cxof128(c: &mut Criterion) { + let data = random_data(DATA_LEN); + let customization = b"bench-customization"; + let mut out = [0u8; 64]; + + let mut group = c.benchmark_group("ascon::AsconCXof128"); + group.throughput(Throughput::Bytes((DATA_LEN + out.len()) as u64)); + group.bench_function( + format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::hash_xof_out()"), + |b| { + b.iter(|| { + AsconCXof128::with_customization(customization) + .unwrap() + .hash_xof_out(black_box(&data), &mut out); + black_box(&out); + }) + }, + ); + group.finish(); +} + +criterion_group!(benches, bench_aead128_encrypt, bench_hash256, bench_xof128, bench_cxof128); +criterion_main!(benches); diff --git a/crypto/ascon/src/ascon_aead128.rs b/crypto/ascon/src/ascon_aead128.rs new file mode 100644 index 00000000..ee34d2cd --- /dev/null +++ b/crypto/ascon/src/ascon_aead128.rs @@ -0,0 +1,865 @@ +//! Ascon-AEAD128 authenticated encryption, as specified in NIST SP 800-232 §4. +//! +//! Rate = 128 bits, capacity = 192 bits, 128-bit key/nonce/tag. Initialization and finalization use +//! `Ascon-p[12]`; associated-data and plaintext/ciphertext blocks use `Ascon-p[8]`. +//! +//! Every byte of plaintext/ciphertext is transformed and emitted as soon as it is seen (no +//! held-back buffering across `do_encrypt_update`/`do_decrypt_update` calls); this is what lets the +//! finalizers be plain `self -> tag` / `self -> Result<(), _>` calls with nothing left to flush. +//! Ascon-AEAD128 permits this because within a 128-bit rate block each plaintext/ciphertext byte +//! is transformed independently of the others in that block; the permutation only runs once a +//! full 16-byte block has been absorbed, or at finalization. +//! +//! [`AsconAead128Encryptor`] / [`AsconAead128Decryptor`] adapt this type's direction-agnostic +//! streaming API (a single [`AsconAead128`] value serves either direction, chosen by a runtime +//! flag to [`AsconAead128::new`]) to [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], whose +//! direction is fixed by the type: each newtype wraps an [`AsconAead128`] already constructed for +//! its own direction and only ever calls that direction's inherent methods, so the wrong-direction +//! panics inside [`AsconAead128::do_encrypt_update`] and friends are unreachable through them. See +//! their docs for why a thin newtype pair rather than encoding the direction into `AsconAead128` +//! itself: that would need a second, incompatible implementation of the single-type [`AEADCipher`] +//! this module also provides, which needs both directions available on the one type. + +use core::fmt::{self, Debug, Display, Formatter}; + +use bouncycastle_core::errors::{KeyMaterialError, SuspendableError, SymmetricCipherError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; +use bouncycastle_core::traits::{ + AEADCipher, AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, + SuspendableKeyed, +}; +use bouncycastle_rng::HashDRBG_SHA512; +use bouncycastle_utils::ct::ct_eq_bytes; +use bouncycastle_utils::secret::Secret; + +use crate::permutation::{AsconState, load_u64_le, p8, p12, store_u64_le}; + +/// Length in bytes of the Ascon-AEAD128 key. +pub const KEY_LEN: usize = 16; +/// Length in bytes of the Ascon-AEAD128 nonce. +pub const NONCE_LEN: usize = 16; +/// Length in bytes of the Ascon-AEAD128 authentication tag. +pub const TAG_LEN: usize = 16; +const RATE: usize = 16; + +/// Ascon-AEAD128 initial value (SP 800-232 Table 14). +const ASCON_IV: u64 = 0x00001000808C0001; + +/// State machine for enforcing the call order and remembering the direction (encrypt/decrypt). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum StateMachine { + EncInit, + EncAad, + EncData, + DecInit, + DecAad, + DecData, +} + +impl StateMachine { + // Stable u8 encoding used when suspending/resuming the AEAD state machine. + fn to_u8(self) -> u8 { + match self { + StateMachine::EncInit => 0, + StateMachine::EncAad => 1, + StateMachine::EncData => 2, + StateMachine::DecInit => 4, + StateMachine::DecAad => 5, + StateMachine::DecData => 6, + } + } + + fn from_u8(v: u8) -> Option { + Some(match v { + 0 => StateMachine::EncInit, + 1 => StateMachine::EncAad, + 2 => StateMachine::EncData, + 4 => StateMachine::DecInit, + 5 => StateMachine::DecAad, + 6 => StateMachine::DecData, + _ => return None, + }) + } + + fn is_encrypt(self) -> bool { + matches!(self, StateMachine::EncInit | StateMachine::EncAad | StateMachine::EncData) + } + + fn is_init(self) -> bool { + matches!(self, StateMachine::EncInit | StateMachine::DecInit) + } +} + +/// An implementation of the Ascon-AEAD128 algorithm (NIST SP 800-232). +/// +/// A single instance performs one operation (encryption or decryption) under one (key, nonce) pair. +/// See [`AsconAead128::new`] for the streaming workflow and [`AsconAead128::encrypt`] / +/// [`AsconAead128::decrypt`] for the one-shot APIs. +#[derive(Clone)] +pub struct AsconAead128 { + // 128-bit secret key (two 64-bit words). It is re-added to the state at finalization, so it must + // be retained; wrapped in `Secret` for volatile-write zeroization on drop. + key: Secret<[u64; 2]>, + // 320-bit internal state (five 64-bit words). Carries keystream/plaintext-derived material, so + // it is likewise wrapped in `Secret`. + state: Secret, + // Byte position (0..RATE) within the current rate block. + pos: usize, + // State machine for enforcing the call order and remembering the direction. + state_machine: StateMachine, +} + +impl AsconAead128 { + /// Validate a [`KeyMaterial`] for use with Ascon-AEAD128 and return its key words. + /// The key must be tagged as a [`KeyType::SymmetricCipherKey`] and carry at least the + /// algorithm's 128-bit security strength (SP 800-232 R1/R2). + fn checked_key(key: &KeyMaterial) -> Result<[u64; 2], SymmetricCipherError> { + if key.key_type() != KeyType::SymmetricCipherKey { + return Err(KeyMaterialError::InvalidKeyType( + "Ascon-AEAD128 requires a SymmetricCipherKey", + ) + .into()); + } + if key.security_strength() < SecurityStrength::_128bit { + return Err(KeyMaterialError::SecurityStrength( + "Ascon-AEAD128 requires a key with at least 128-bit security strength", + ) + .into()); + } + let bytes = key.ref_to_bytes(); + if bytes.len() != KEY_LEN { + return Err(KeyMaterialError::InvalidLength.into()); + } + Ok([load_u64_le(bytes, 0), load_u64_le(bytes, 8)]) + } + + /// Draw a fresh, unique 128-bit nonce from the library's default OS-seeded DRBG. + /// + /// The one-shot APIs of main's cipher framework generate the init data / nonce internally, so + /// Ascon's per-encryption nonce-uniqueness requirement (SP 800-232 R3) is satisfied by sourcing + /// each nonce from a CSPRNG. Callers who need deterministic, caller-supplied nonces should use + /// the inherent streaming API ([`AsconAead128::new`]). + fn fresh_nonce() -> Result<[u8; NONCE_LEN], SymmetricCipherError> { + let mut rng = HashDRBG_SHA512::new_from_os(); + let mut nonce = [0u8; NONCE_LEN]; + rng.next_bytes_out(&mut nonce)?; + Ok(nonce) + } + + /// Create a new streaming instance. + /// * `key` is validated as a [`KeyType::SymmetricCipherKey`] with at least 128-bit strength. + /// * `nonce` is the 128-bit nonce. It **must** be unique per encryption under a given key. + /// * `ad` is optional associated data (authenticated, not encrypted); processed immediately. + /// * `for_encryption` is true for encryption, false for decryption. + pub fn new( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ad: Option<&[u8]>, + for_encryption: bool, + ) -> Result { + let key_words = Self::checked_key(key)?; + let mut key_secret: Secret<[u64; 2]> = Secret::new(); + *key_secret = key_words; + + let mut state: Secret = Secret::new(); + // Initialization (SP 800-232 §4.1.1 step 1 / Eq. 15-17): S = IV||K||N, then Ascon-p[12], + // then XOR K into the last 128 bits. + state[0] = ASCON_IV; + state[1] = key_words[0]; + state[2] = key_words[1]; + state[3] = load_u64_le(nonce, 0); + state[4] = load_u64_le(nonce, 8); + p12(&mut state); + state[3] ^= key_words[0]; + state[4] ^= key_words[1]; + + let mut aead = AsconAead128 { + key: key_secret, + state, + pos: 0, + state_machine: if for_encryption { + StateMachine::EncInit + } else { + StateMachine::DecInit + }, + }; + if let Some(ad_bytes) = ad { + // infallible: a freshly constructed instance has processed no data yet, so + // `check_aad` cannot return `StateError`. + aead.do_update_aad(ad_bytes).unwrap(); + } + Ok(aead) + } + + /// One-shot authenticated encryption with a caller-supplied nonce (SP 800-232 Algorithm 3). + /// Writes ciphertext followed by the 128-bit tag into `out`, which must be at least + /// `plaintext.len() + 16` bytes. Returns the number of bytes written. + pub fn encrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ad: Option<&[u8]>, + plaintext: &[u8], + out: &mut [u8], + ) -> Result { + let needed = plaintext.len() + TAG_LEN; + if out.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "Ascon-AEAD128 output buffer too small (need plaintext length + 16)", + needed, + )); + } + let mut cipher = Self::new(key, nonce, ad, true)?; + out[..plaintext.len()].copy_from_slice(plaintext); + cipher.do_encrypt_update(&mut out[..plaintext.len()]); + let tag = cipher.do_encrypt_final(); + out[plaintext.len()..needed].copy_from_slice(&tag); + Ok(needed) + } + + /// One-shot authenticated decryption with a caller-supplied nonce (SP 800-232 Algorithm 4). + /// `ciphertext` is the ciphertext followed by the 128-bit tag. Writes the recovered plaintext + /// into `out`, which must be at least `ciphertext.len() - 16` bytes. Returns the number of + /// bytes written, or [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify -- + /// in which case `out` is zeroized before returning. + pub fn decrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ad: Option<&[u8]>, + ciphertext: &[u8], + out: &mut [u8], + ) -> Result { + if ciphertext.len() < TAG_LEN { + return Err(SymmetricCipherError::GenericError( + "Ascon-AEAD128 ciphertext shorter than tag", + )); + } + let pt_len = ciphertext.len() - TAG_LEN; + if out.len() < pt_len { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "Ascon-AEAD128 output buffer too small", + pt_len, + )); + } + let mut cipher = Self::new(key, nonce, ad, false)?; + out[..pt_len].copy_from_slice(&ciphertext[..pt_len]); + cipher.do_decrypt_update(&mut out[..pt_len]); + // infallible: ciphertext.len() - pt_len == TAG_LEN by construction above. + let tag: &[u8; TAG_LEN] = ciphertext[pt_len..].try_into().unwrap(); + match cipher.do_decrypt_final(tag) { + Ok(()) => Ok(pt_len), + Err(e) => { + out[..pt_len].fill(0); + Err(e) + } + } + } + + /// Read the value of state byte `pos` (0 = LSB of word 0, ..., 15 = MSB of word 1). + fn state_byte(&self, pos: usize) -> u8 { + let word = if pos < 8 { self.state[0] } else { self.state[1] }; + (word >> ((pos % 8) * 8)) as u8 + } + + /// XOR `b` into state byte `pos`. + fn xor_state_byte(&mut self, pos: usize, b: u8) { + let shifted = (b as u64) << ((pos % 8) * 8); + if pos < 8 { self.state[0] ^= shifted } else { self.state[1] ^= shifted } + } + + /// Overwrite state byte `pos` with `b`. + fn set_state_byte(&mut self, pos: usize, b: u8) { + let shift = (pos % 8) * 8; + let mask = !(0xFFu64 << shift); + let shifted = (b as u64) << shift; + if pos < 8 { + self.state[0] = (self.state[0] & mask) | shifted; + } else { + self.state[1] = (self.state[1] & mask) | shifted; + } + } + + /// Advance to the next byte position, running `Ascon-p[8]` and wrapping back to 0 once a full + /// rate block (16 bytes) has been absorbed. + fn advance(&mut self) { + self.pos += 1; + if self.pos == RATE { + p8(&mut self.state); + self.pos = 0; + } + } + + fn absorb_aad_byte(&mut self, b: u8) { + self.xor_state_byte(self.pos, b); + self.advance(); + } + + fn encrypt_byte(&mut self, p: u8) -> u8 { + self.xor_state_byte(self.pos, p); + let c = self.state_byte(self.pos); + self.advance(); + c + } + + fn decrypt_byte(&mut self, c: u8) -> u8 { + let prev = self.state_byte(self.pos); + self.set_state_byte(self.pos, c); + self.advance(); + prev ^ c + } + + fn check_aad(&mut self) -> Result<(), SymmetricCipherError> { + match self.state_machine { + StateMachine::EncInit => self.state_machine = StateMachine::EncAad, + StateMachine::DecInit => self.state_machine = StateMachine::DecAad, + StateMachine::EncAad | StateMachine::DecAad => {} + StateMachine::EncData | StateMachine::DecData => { + return Err(SymmetricCipherError::StateError( + "Ascon-AEAD128: associated data must be processed before plaintext/ciphertext", + )); + } + } + Ok(()) + } + + // Ends the associated-data phase (SP 800-232 §4.1.1/§4.1.2 step 2): pads and absorbs the + // final (possibly empty) AAD block only if any AAD was actually supplied, then applies the + // domain-separation bit unconditionally. + fn finish_aad(&mut self) { + if matches!(self.state_machine, StateMachine::EncAad | StateMachine::DecAad) { + self.xor_state_byte(self.pos, 0x01); + p8(&mut self.state); + self.pos = 0; + } + // Domain separation (Eq. 22/40: S ^= (0^319 || 1)). + self.state[4] ^= 0x8000000000000000; + self.state_machine = match self.state_machine { + StateMachine::EncInit | StateMachine::EncAad => StateMachine::EncData, + StateMachine::DecInit | StateMachine::DecAad => StateMachine::DecData, + StateMachine::EncData | StateMachine::DecData => unreachable!(), + }; + } + + fn check_data(&mut self) { + if !matches!(self.state_machine, StateMachine::EncData | StateMachine::DecData) { + self.finish_aad(); + } + } + + // Finalization (SP 800-232 §4.1.1 step 4 / §4.1.2 step 4, Eq. 30-32 / 49-51): re-add the key, + // permute with Ascon-p[12], and add the key again; the tag is the resulting last 128 bits. + fn finish_data(&mut self) -> [u8; TAG_LEN] { + self.state[2] ^= self.key[0]; + self.state[3] ^= self.key[1]; + p12(&mut self.state); + self.state[3] ^= self.key[0]; + self.state[4] ^= self.key[1]; + + let mut tag = [0u8; TAG_LEN]; + store_u64_le(&mut tag, 0, self.state[3]); + store_u64_le(&mut tag, 8, self.state[4]); + tag + } + + /// Process associated data (AAD) bytes. May be called multiple times, but only before any + /// plaintext/ciphertext is processed; an empty `input` is always a no-op, even after data. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if `input` is non-empty and plaintext/ciphertext has + /// already been processed. + pub fn do_update_aad(&mut self, input: &[u8]) -> Result<(), SymmetricCipherError> { + if input.is_empty() { + return Ok(()); + } + self.check_aad()?; + + let mut input = input; + while !input.is_empty() { + if self.pos == 0 && input.len() >= RATE { + self.state[0] ^= load_u64_le(input, 0); + self.state[1] ^= load_u64_le(input, 8); + p8(&mut self.state); + input = &input[RATE..]; + } else { + self.absorb_aad_byte(input[0]); + input = &input[1..]; + } + } + Ok(()) + } + + /// Encrypt `data` in place (SP 800-232 §4.1.1 step 3). Every byte is transformed and emitted + /// immediately; nothing is buffered across calls. + pub fn do_encrypt_update(&mut self, data: &mut [u8]) { + if !self.state_machine.is_encrypt() { + panic!("Ascon-AEAD128: do_encrypt_update called on a decryptor"); + } + self.check_data(); + + let mut data = data; + while !data.is_empty() { + if self.pos == 0 && data.len() >= RATE { + let c0 = self.state[0] ^ load_u64_le(data, 0); + let c1 = self.state[1] ^ load_u64_le(data, 8); + store_u64_le(data, 0, c0); + store_u64_le(data, 8, c1); + self.state[0] = c0; + self.state[1] = c1; + p8(&mut self.state); + data = &mut data[RATE..]; + } else { + data[0] = self.encrypt_byte(data[0]); + data = &mut data[1..]; + } + } + } + + /// Finish encryption; returns the 128-bit tag (SP 800-232 §4.1.1 steps 3-4). Pads the final + /// (possibly empty) plaintext block; no further bytes are emitted here since every + /// plaintext/ciphertext byte was already written by `do_encrypt_update`. + pub fn do_encrypt_final(mut self) -> [u8; TAG_LEN] { + if !self.state_machine.is_encrypt() { + panic!("Ascon-AEAD128: do_encrypt_final called on a decryptor"); + } + self.check_data(); + // Padding of the final (possibly empty) plaintext block (Eq. 27). + self.xor_state_byte(self.pos, 0x01); + self.finish_data() + } + + /// Decrypt `data` in place (SP 800-232 §4.1.2 step 3). Every byte is transformed and emitted + /// immediately; the plaintext is **not** authenticated until [`AsconAead128::do_decrypt_final`] + /// returns `Ok`. + pub fn do_decrypt_update(&mut self, data: &mut [u8]) { + if self.state_machine.is_encrypt() { + panic!("Ascon-AEAD128: do_decrypt_update called on an encryptor"); + } + self.check_data(); + + let mut data = data; + while !data.is_empty() { + if self.pos == 0 && data.len() >= RATE { + let t0 = load_u64_le(data, 0); + let t1 = load_u64_le(data, 8); + store_u64_le(data, 0, self.state[0] ^ t0); + store_u64_le(data, 8, self.state[1] ^ t1); + self.state[0] = t0; + self.state[1] = t1; + p8(&mut self.state); + data = &mut data[RATE..]; + } else { + data[0] = self.decrypt_byte(data[0]); + data = &mut data[1..]; + } + } + } + + /// Finish decryption, checking `tag` in constant time (SP 800-232 §4.1.2 steps 3-4). + pub fn do_decrypt_final(mut self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError> { + if self.state_machine.is_encrypt() { + panic!("Ascon-AEAD128: do_decrypt_final called on an encryptor"); + } + self.check_data(); + // Padding of the final (possibly empty) ciphertext block (Eq. 47). + self.xor_state_byte(self.pos, 0x01); + let computed = self.finish_data(); + + if !ct_eq_bytes(&computed, tag) { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok(()) + } +} + +impl Algorithm for AsconAead128 { + const ALG_NAME: &'static str = "Ascon-AEAD128"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +// Ascon-AEAD128 as an `AEADCipher`. `encrypt`/`encrypt_out`/`decrypt`/`decrypt_out` are the +// "basic" (non-AEAD) view: the init data is the 128-bit nonce, and the ciphertext produced by +// these APIs is `Ascon ciphertext || 16-byte tag` (empty AAD). `aead_*` are the full AEAD view +// with associated data and a separate tag. +impl AEADCipher for AsconAead128 { + #[cfg(feature = "std")] + fn encrypt( + key: &KeyMaterial, + plaintext: &[u8], + ) -> Result<([u8; NONCE_LEN], Vec), SymmetricCipherError> { + let mut ciphertext = vec![0u8; plaintext.len() + TAG_LEN]; + let (nonce, written) = Self::encrypt_out(key, plaintext, &mut ciphertext)?; + ciphertext.truncate(written); + Ok((nonce, ciphertext)) + } + + fn encrypt_out( + key: &KeyMaterial, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError> { + let _ = Self::checked_key(key)?; + let nonce = Self::fresh_nonce()?; + // No associated data for the plain, non-AEAD view; the tag is appended to `ciphertext`. + // `encrypt` itself checks that `ciphertext` is long enough. + let written = Self::encrypt(key, &nonce, None, plaintext, ciphertext)?; + Ok((nonce, written)) + } + + #[cfg(feature = "std")] + fn decrypt( + key: &KeyMaterial, + init_data: [u8; NONCE_LEN], + ciphertext: &[u8], + ) -> Result, SymmetricCipherError> { + if ciphertext.len() < TAG_LEN { + return Err(SymmetricCipherError::GenericError( + "Ascon-AEAD128 ciphertext shorter than tag", + )); + } + let mut plaintext = vec![0u8; ciphertext.len() - TAG_LEN]; + let written = Self::decrypt_out(key, init_data, ciphertext, &mut plaintext)?; + plaintext.truncate(written); + Ok(plaintext) + } + + fn decrypt_out( + key: &KeyMaterial, + init_data: [u8; NONCE_LEN], + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + let _ = Self::checked_key(key)?; + if ciphertext.len() < TAG_LEN { + return Err(SymmetricCipherError::GenericError( + "Ascon-AEAD128 ciphertext shorter than tag", + )); + } + let pt_len = ciphertext.len() - TAG_LEN; + if plaintext.len() < pt_len { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "Ascon-AEAD128 plaintext buffer too small", + pt_len, + )); + } + // `ciphertext` is `Ascon ciphertext || 16-byte tag`; `decrypt` splits it internally. + // This plain, non-AEAD view has no AAD and so nothing that distinguishes an + // authentication failure from any other decryption failure; report both as + // `DecryptionFailed`, matching the trait's documented "the caller learns only that + // decryption failed". `AEADTagCheckFailed` is reserved for the AEAD view + // (`aead_decrypt`/`aead_decrypt_out`), which is honest about there being a separate tag. + Self::decrypt(key, &init_data, None, ciphertext, plaintext).map_err(|e| match e { + SymmetricCipherError::AEADTagCheckFailed => SymmetricCipherError::DecryptionFailed, + other => other, + }) + } + + #[cfg(feature = "std")] + fn aead_encrypt( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ) -> Result<([u8; NONCE_LEN], Vec, [u8; TAG_LEN]), SymmetricCipherError> { + let mut ciphertext = vec![0u8; plaintext.len()]; + let (nonce, written, tag) = Self::aead_encrypt_out(key, aad, plaintext, &mut ciphertext)?; + ciphertext.truncate(written); + Ok((nonce, ciphertext, tag)) + } + + fn aead_encrypt_out( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { + let _ = Self::checked_key(key)?; + if ciphertext.len() < plaintext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "Ascon-AEAD128 ciphertext buffer too small", + plaintext.len(), + )); + } + let nonce = Self::fresh_nonce()?; + let aad_opt = if aad.is_empty() { None } else { Some(aad) }; + let mut cipher = Self::new(key, &nonce, aad_opt, true)?; + ciphertext[..plaintext.len()].copy_from_slice(plaintext); + cipher.do_encrypt_update(&mut ciphertext[..plaintext.len()]); + let tag = cipher.do_encrypt_final(); + Ok((nonce, plaintext.len(), tag)) + } + + fn do_aead_encrypt_final(self) -> Result<[u8; TAG_LEN], SymmetricCipherError> { + Ok(self.do_encrypt_final()) + } + + #[cfg(feature = "std")] + fn aead_decrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], + ) -> Result, SymmetricCipherError> { + let mut plaintext = vec![0u8; ciphertext.len()]; + let written = Self::aead_decrypt_out(key, nonce, aad, ciphertext, tag, &mut plaintext)?; + plaintext.truncate(written); + Ok(plaintext) + } + + fn aead_decrypt_out( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], + plaintext: &mut [u8], + ) -> Result { + let _ = Self::checked_key(key)?; + if plaintext.len() < ciphertext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "Ascon-AEAD128 plaintext buffer too small", + ciphertext.len(), + )); + } + let aad_opt = if aad.is_empty() { None } else { Some(aad) }; + let mut cipher = Self::new(key, nonce, aad_opt, false)?; + plaintext[..ciphertext.len()].copy_from_slice(ciphertext); + cipher.do_decrypt_update(&mut plaintext[..ciphertext.len()]); + match cipher.do_decrypt_final(tag) { + Ok(()) => Ok(ciphertext.len()), + Err(e) => { + // A failed tag check must not leave plaintext in the caller's buffer. + plaintext[..ciphertext.len()].fill(0); + Err(e) + } + } + } + + fn do_aead_decrypt_final(self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError> { + self.do_decrypt_final(tag) + } +} + +/// Adapts [`AsconAead128`]'s encrypting direction to [`AEADCipherEncryptor`]; see the module docs +/// for why this is a thin wrapper rather than a change to `AsconAead128` itself. +pub struct AsconAead128Encryptor(AsconAead128); + +impl Algorithm for AsconAead128Encryptor { + const ALG_NAME: &'static str = AsconAead128::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = AsconAead128::MAX_SECURITY_STRENGTH; +} + +impl AEADCipherEncryptor for AsconAead128Encryptor { + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + let nonce = AsconAead128::fresh_nonce()?; + Ok((Self(AsconAead128::new(key, &nonce, None, true)?), nonce)) + } + + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + let mut nonce = [0u8; NONCE_LEN]; + rng.next_bytes_out(&mut nonce)?; + Ok((Self(AsconAead128::new(key, &nonce, None, true)?), nonce)) + } + + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + self.0.do_update_aad(aad) + } + + /// Ascon-AEAD128 never buffers: every byte given is a byte returned. + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + if ciphertext.len() < plaintext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "ciphertext", + plaintext.len(), + )); + } + let out = &mut ciphertext[..plaintext.len()]; + out.copy_from_slice(plaintext); + self.0.do_encrypt_update(out); + Ok(plaintext.len()) + } + + /// `output` is always `[u8; 0]`: nothing is ever held back to flush. + fn do_encrypt_final( + self, + _output: &mut [u8; 0], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + Ok((0, self.0.do_encrypt_final())) + } +} + +/// Adapts [`AsconAead128`]'s decrypting direction to [`AEADCipherDecryptor`]; see the module docs +/// for why this is a thin wrapper rather than a change to `AsconAead128` itself. +pub struct AsconAead128Decryptor(AsconAead128); + +impl Algorithm for AsconAead128Decryptor { + const ALG_NAME: &'static str = AsconAead128::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = AsconAead128::MAX_SECURITY_STRENGTH; +} + +impl AEADCipherDecryptor for AsconAead128Decryptor { + fn do_decrypt_init( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ) -> Result { + Ok(Self(AsconAead128::new(key, nonce, None, false)?)) + } + + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + self.0.do_update_aad(aad) + } + + /// Ascon-AEAD128 never buffers: every byte given is a byte returned. + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + if plaintext.len() < ciphertext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "plaintext", + ciphertext.len(), + )); + } + let out = &mut plaintext[..ciphertext.len()]; + out.copy_from_slice(ciphertext); + self.0.do_decrypt_update(out); + Ok(ciphertext.len()) + } + + /// `output` is always `[u8; 0]`: nothing is ever held back to flush. + fn do_decrypt_final( + self, + tag: &[u8; TAG_LEN], + _output: &mut [u8; 0], + ) -> Result { + self.0.do_decrypt_final(tag)?; + Ok(0) + } +} + +impl Debug for AsconAead128 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "AsconAead128 (key/state masked)") + } +} + +impl Display for AsconAead128 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "AsconAead128 (key/state masked)") + } +} + +/// Length in bytes of the serialized state of [`AsconAead128`]. +/// Layout: 3-byte library version || 1-byte state tag || 40-byte permutation state (5 × u64 LE) +/// || 1-byte byte position within the current rate block || 1-byte call-state/direction. +/// The secret key is **not** serialized; it is re-supplied to [`SuspendableKeyed::from_suspended`]. +pub const SUSPENDED_ASCON_AEAD128_STATE_LEN: usize = 46; + +const AEAD128_STATE_TAG: u8 = 0x04; + +impl SuspendableKeyed for AsconAead128 { + // The 128-bit key must be re-supplied when resuming; it is never part of the serialized state, + // and is re-validated exactly as `new()` validates it. + type Key = KeyMaterial; + + fn suspend(self) -> [u8; SUSPENDED_ASCON_AEAD128_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_AEAD128_STATE_LEN]; + // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_AEAD128_STATE_LEN - 3 = 43 bytes. + let out: &mut [u8; SUSPENDED_ASCON_AEAD128_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = AEAD128_STATE_TAG; + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&self.state[i].to_le_bytes()); + } + debug_assert!(self.pos < RATE); + out[41] = self.pos as u8; + out[42] = self.state_machine.to_u8(); + + out_to_return + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_AEAD128_STATE_LEN], + key: &Self::Key, + ) -> Result { + // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_AEAD128_STATE_LEN - 3 = 43 bytes. + let input: &[u8; SUSPENDED_ASCON_AEAD128_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != AEAD128_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + let mut s = Secret::::new(); + for i in 0..5 { + // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. + s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + let pos = input[41] as usize; + if pos >= RATE { + return Err(SuspendableError::InvalidData); + } + let state_machine = + StateMachine::from_u8(input[42]).ok_or(SuspendableError::InvalidData)?; + // A nonzero byte position implies at least one AAD/data byte has already been absorbed + // into the current rate block, which is only possible once the *Aad or *Data phase has + // begun -- never while still in *Init. + if pos != 0 && state_machine.is_init() { + return Err(SuspendableError::InvalidData); + } + + let key_words = Self::checked_key(key).map_err(|_| SuspendableError::InvalidData)?; + let mut key_secret = Secret::<[u64; 2]>::new(); + *key_secret = key_words; + + Ok(AsconAead128 { key: key_secret, state: s, pos, state_machine }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // StateMachine is private, so its to_u8/from_u8 round trip -- exercised end-to-end via + // suspend/resume in tests/aead128_tests.rs for the states reachable there -- is pinned + // directly here for every discriminant, including ones a successful resume never needs to + // decode into (EncInit/EncAad/DecInit/DecAad never survive to be the *end* state of a + // still-running cipher in the integration tests, since further processing always advances + // them to *Data). + #[test] + fn state_machine_u8_round_trip() { + let all = [ + StateMachine::EncInit, + StateMachine::EncAad, + StateMachine::EncData, + StateMachine::DecInit, + StateMachine::DecAad, + StateMachine::DecData, + ]; + for s in all { + assert_eq!(StateMachine::from_u8(s.to_u8()), Some(s), "round trip failed for {s:?}"); + } + // Unassigned discriminants (3 and 7 are deliberately skipped by to_u8's encoding) must + // be rejected, not silently mapped to a variant. + for v in [3u8, 7, 200] { + assert_eq!(StateMachine::from_u8(v), None, "discriminant {v} must be rejected"); + } + } +} diff --git a/crypto/ascon/src/ascon_cxof128.rs b/crypto/ascon/src/ascon_cxof128.rs new file mode 100644 index 00000000..4a0b055f --- /dev/null +++ b/crypto/ascon/src/ascon_cxof128.rs @@ -0,0 +1,218 @@ +//! Ascon-CXOF128 customized extendable-output function (NIST SP 800-232 §5.3). +//! +//! A variant of Ascon-XOF128 that first absorbs a user-supplied customization string `Z` +//! (length-prefixed per SP 800-232 Alg. 7) to provide domain separation. Same sponge parameters as +//! Ascon-XOF128 (rate = 64 bits, capacity = 256 bits, `Ascon-p[12]`). + +use bouncycastle_core::errors::{HashError, SuspendableError}; +use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; +use bouncycastle_core::traits::{Algorithm, SecurityStrength, Suspendable, XOF}; +use bouncycastle_utils::secret::Secret; + +use crate::sponge::{RATE, Sponge}; + +/// Maximum customization-string length in bytes (2048 bits, per SP 800-232 §5.3). +const MAX_CUSTOMIZATION_BYTES: usize = 256; + +/// Ascon-CXOF128 customized extendable-output function (NIST SP 800-232 §5.3). +#[derive(Clone)] +pub struct AsconCXof128 { + sponge: Sponge, +} + +impl AsconCXof128 { + /// Create a new Ascon-CXOF128 instance with no customization string. + pub fn new() -> Self { + // Precomputed state after initializing and then absorbing an empty customization string + // (SP 800-232 Algorithm 7 with |Z| = 0): starting from the Table 12 CXOF128 initialization + // state, XOR the length word Z_0 = int64(0) into S[0..63], Ascon-p[12], then XOR the + // pad-only last customization block (Eq. 77: pad(empty, 64) = 0x01 || 0^63) into S[0..63] + // and Ascon-p[12] again. Recomputed from those raw Table 12 words and pinned by + // `permutation::tests::cxof128_empty_customization_state_matches_algorithm_7`. + let mut sponge = Sponge::from_state([ + 0x500CCCC894E3C9E8, 0x5BED06F28F71248D, 0x3B03A0F930AFD512, 0x112EF093AA5C698B, + 0x00C8356340A347F0, + ]); + sponge.reset_buffer(); + Self { sponge } + } + + /// Create a new Ascon-CXOF128 instance with the given customization string `z`. + /// + /// Returns [`HashError::InvalidInput`] if `z` is longer than 256 bytes (2048 bits, the bound + /// required by SP 800-232 §5.3). + pub fn with_customization(z: &[u8]) -> Result { + if z.len() > MAX_CUSTOMIZATION_BYTES { + return Err(HashError::InvalidInput( + "Ascon-CXOF128 customization string exceeds 256 bytes", + )); + } + if z.is_empty() { + return Ok(Self::new()); + } + + // Precomputed state after the initialization permutation (SP 800-232 Table 12). + let mut sponge = Sponge::from_state([ + 0x675527C2A0E8DE03, 0x43D12D7DC0377BBC, 0xE9901DEC426E81B5, 0x2AB14907720780B6, + 0x8F3F1D02D432BC46, + ]); + + // Z0 = int64(|Z|) in bits, then absorb the parsed/padded customization blocks + // (SP 800-232 §5.3 Eq. 75-78 / Algorithm 7, "Customization" loop). + let bit_length = (z.len() as u64) << 3; + sponge.xor_word0(bit_length); + sponge.permute(); + sponge.absorb(z); + sponge.pad_and_absorb(); + sponge.permute(); + + // Customization is complete; reset the buffer to begin the message-absorb phase. + sponge.reset_buffer(); + Ok(Self { sponge }) + } + + // Squeeze `output.len()` bytes of output. May be called multiple times; the first call ends the + // absorb phase by padding and absorbing the final block. Returns the number of bytes written. + fn squeeze_into(&mut self, output: &mut [u8]) -> usize { + let written = output.len(); + if !self.sponge.squeezing() { + self.sponge.pad_and_absorb(); + } + self.sponge.squeeze(output); + written + } +} + +impl Default for AsconCXof128 { + fn default() -> Self { + Self::new() + } +} + +impl Algorithm for AsconCXof128 { + const ALG_NAME: &'static str = "Ascon-CXOF128"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl XOF for AsconCXof128 { + fn hash_xof(mut self, data: &[u8], result_len: usize) -> Vec { + self.sponge.absorb(data); + let mut out = vec![0u8; result_len]; + self.squeeze_into(&mut out); + out + } + + fn hash_xof_out(mut self, data: &[u8], output: &mut [u8]) -> usize { + self.sponge.absorb(data); + self.squeeze_into(output) + } + + fn absorb(&mut self, data: &[u8]) -> Result<(), HashError> { + if self.sponge.squeezing() { + return Err(HashError::InvalidState( + "Ascon-CXOF128 cannot absorb after squeezing has begun", + )); + } + self.sponge.absorb(data); + Ok(()) + } + + fn absorb_last_partial_byte( + &mut self, + _partial_byte: u8, + _num_partial_bits: usize, + ) -> Result<(), HashError> { + Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte input")) + } + + fn squeeze(&mut self, num_bytes: usize) -> Vec { + let mut out = vec![0u8; num_bytes]; + self.squeeze_into(&mut out); + out + } + + fn squeeze_out(&mut self, output: &mut [u8]) -> usize { + self.squeeze_into(output) + } + + fn squeeze_partial_byte_final(self, _num_bits: usize) -> Result { + Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte output")) + } + + fn squeeze_partial_byte_final_out( + self, + _num_bits: usize, + _output: &mut u8, + ) -> Result<(), HashError> { + Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte output")) + } + + fn max_security_strength(&self) -> SecurityStrength { + SecurityStrength::_128bit + } +} + +/// Length in bytes of the serialized state of [`AsconCXof128`]. +/// Layout: 3-byte library version || 1-byte state tag || 40-byte sponge state (5 × u64 LE) +/// || 8-byte rate buffer || 1-byte buffer position || 1-byte squeezing flag. +/// +/// Note: the customization string is absorbed at construction time and is not part of the +/// suspended state; resuming continues the message-absorb / squeeze phase already in progress. +pub const SUSPENDED_ASCON_CXOF128_STATE_LEN: usize = 54; + +// Distinguishes an Ascon-CXOF128 serialized state from the other (same-shaped) Ascon sponge states. +const CXOF128_STATE_TAG: u8 = 0x03; + +impl Suspendable for AsconCXof128 { + fn suspend(self) -> [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_CXOF128_STATE_LEN]; + // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_CXOF128_STATE_LEN - 3 = 51 bytes. + let out: &mut [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = CXOF128_STATE_TAG; + let state = self.sponge.state_words(); + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); + } + out[41..49].copy_from_slice(&self.sponge.buf_bytes()); + debug_assert!(self.sponge.buf_pos() <= RATE); + out[49] = self.sponge.buf_pos() as u8; + out[50] = self.sponge.squeezing() as u8; + + out_to_return + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN], + ) -> Result { + // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_CXOF128_STATE_LEN - 3 = 51 bytes. + let input: &[u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != CXOF128_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + let mut s = Secret::<[u64; 5]>::new(); + for i in 0..5 { + // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. + s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + let mut buf = Secret::<[u8; RATE]>::new(); + buf.copy_from_slice(&input[41..49]); + let buf_pos = input[49] as usize; + let squeezing = match input[50] { + 0 => false, + 1 => true, + _ => return Err(SuspendableError::InvalidData), + }; + // While absorbing, buf_pos must be < RATE (a full buffer is drained immediately); once + // squeezing, buf_pos may equal RATE (meaning "no leftover squeezed byte buffered"). + let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; + if !valid_pos { + return Err(SuspendableError::InvalidData); + } + + Ok(AsconCXof128 { sponge: Sponge::from_parts(s, buf, buf_pos, squeezing) }) + } +} diff --git a/crypto/ascon/src/ascon_hash256.rs b/crypto/ascon/src/ascon_hash256.rs new file mode 100644 index 00000000..9d2b87d5 --- /dev/null +++ b/crypto/ascon/src/ascon_hash256.rs @@ -0,0 +1,185 @@ +//! Ascon-Hash256 cryptographic hash (NIST SP 800-232 §5.1), producing a 256-bit digest. +//! +//! Sponge mode over `Ascon-p[12]` with rate = 64 bits, capacity = 256 bits. + +use bouncycastle_core::errors::{HashError, SuspendableError}; +use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; +use bouncycastle_core::traits::{Algorithm, Hash, HashAlgParams, SecurityStrength, Suspendable}; +use bouncycastle_utils::secret::Secret; + +use crate::sponge::{RATE, Sponge}; + +const DIGEST_BYTES: usize = 32; + +/// Ascon-Hash256 hash function (NIST SP 800-232 §5.1), producing a 256-bit digest. +#[derive(Clone)] +pub struct AsconHash256 { + sponge: Sponge, +} + +impl AsconHash256 { + /// Creates a new AsconHash256 instance. + pub fn new() -> Self { + // Precomputed state after the initialization permutation (SP 800-232 Table 12). + Self { + sponge: Sponge::from_state([ + 0x9B1E_5494_E934_D681, 0x4BC3_A01E_3337_51D2, 0xAE65_396C_6B34_B81A, + 0x3C7F_D4A4_D56A_4DB3, 0x1A5C_4649_06C5_976D, + ]), + } + } + + /// One-shot hash of `data`, returning the 32-byte digest. + pub fn digest(data: &[u8]) -> [u8; DIGEST_BYTES] { + let mut hasher = Self::new(); + hasher.sponge.absorb(data); + let mut out = [0u8; DIGEST_BYTES]; + hasher.squeeze_into(&mut out); + out + } + + // Pad, absorb the final block, and squeeze the four 64-bit digest blocks (SP 800-232 + // Algorithm 5). The 32-byte digest is exactly RATE * 4 bytes, so a single generic + // `Sponge::squeeze()` call over the whole output produces all four blocks with no leftover. + fn squeeze_into(&mut self, output: &mut [u8; DIGEST_BYTES]) { + self.sponge.pad_and_absorb(); + self.sponge.squeeze(output); + } +} + +impl Default for AsconHash256 { + fn default() -> Self { + Self::new() + } +} + +impl Algorithm for AsconHash256 { + const ALG_NAME: &'static str = "Ascon-Hash256"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl HashAlgParams for AsconHash256 { + const OUTPUT_LEN: usize = DIGEST_BYTES; + const BLOCK_LEN: usize = RATE; +} + +impl Hash for AsconHash256 { + fn block_bitlen(&self) -> usize { + RATE * 8 + } + + fn output_len(&self) -> usize { + DIGEST_BYTES + } + + fn hash(mut self, data: &[u8]) -> Vec { + self.sponge.absorb(data); + let mut out = [0u8; DIGEST_BYTES]; + self.squeeze_into(&mut out); + out.to_vec() + } + + fn hash_out(mut self, data: &[u8], output: &mut [u8]) -> usize { + self.sponge.absorb(data); + output.fill(0); + let mut out = [0u8; DIGEST_BYTES]; + self.squeeze_into(&mut out); + let n = core::cmp::min(output.len(), DIGEST_BYTES); + output[..n].copy_from_slice(&out[..n]); + n + } + + fn do_update(&mut self, data: &[u8]) { + self.sponge.absorb(data); + } + + fn do_final(mut self) -> Vec { + let mut out = [0u8; DIGEST_BYTES]; + self.squeeze_into(&mut out); + out.to_vec() + } + + fn do_final_out(mut self, output: &mut [u8]) -> usize { + output.fill(0); + let mut out = [0u8; DIGEST_BYTES]; + self.squeeze_into(&mut out); + let n = core::cmp::min(output.len(), DIGEST_BYTES); + output[..n].copy_from_slice(&out[..n]); + n + } + + fn do_final_partial_bits( + self, + _partial_byte: u8, + _num_partial_bits: usize, + ) -> Result, HashError> { + Err(HashError::InvalidInput("Ascon-Hash256 does not support partial byte input")) + } + + fn do_final_partial_bits_out( + self, + _partial_byte: u8, + _num_partial_bits: usize, + _output: &mut [u8], + ) -> Result { + Err(HashError::InvalidInput("Ascon-Hash256 does not support partial byte input")) + } + + fn max_security_strength(&self) -> SecurityStrength { + SecurityStrength::_128bit + } +} + +/// Length in bytes of the serialized state of [`AsconHash256`]. +/// Layout: 3-byte library version || 1-byte state tag || 40-byte sponge state (5 × u64 LE) +/// || 8-byte rate buffer || 1-byte buffer position. +pub const SUSPENDED_ASCON_HASH256_STATE_LEN: usize = 53; + +// Distinguishes an Ascon-Hash256 serialized state from the other (same-shaped) Ascon sponge states. +const HASH256_STATE_TAG: u8 = 0x01; + +impl Suspendable for AsconHash256 { + fn suspend(self) -> [u8; SUSPENDED_ASCON_HASH256_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_HASH256_STATE_LEN]; + // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_HASH256_STATE_LEN - 3 = 50 bytes. + let out: &mut [u8; SUSPENDED_ASCON_HASH256_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = HASH256_STATE_TAG; + let state = self.sponge.state_words(); + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); + } + out[41..49].copy_from_slice(&self.sponge.buf_bytes()); + // buf_pos is always < RATE (8) before squeezing has begun, so it fits in one byte. + debug_assert!(self.sponge.buf_pos() < RATE); + out[49] = self.sponge.buf_pos() as u8; + + out_to_return + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_HASH256_STATE_LEN], + ) -> Result { + // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_HASH256_STATE_LEN - 3 = 50 bytes. + let input: &[u8; SUSPENDED_ASCON_HASH256_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != HASH256_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + let mut s = Secret::<[u64; 5]>::new(); + for i in 0..5 { + // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. + s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + let mut buf = Secret::<[u8; RATE]>::new(); + buf.copy_from_slice(&input[41..49]); + let buf_pos = input[49] as usize; + if buf_pos >= RATE { + return Err(SuspendableError::InvalidData); + } + + Ok(AsconHash256 { sponge: Sponge::from_parts(s, buf, buf_pos, false) }) + } +} diff --git a/crypto/ascon/src/ascon_xof128.rs b/crypto/ascon/src/ascon_xof128.rs new file mode 100644 index 00000000..0b6e8a8f --- /dev/null +++ b/crypto/ascon/src/ascon_xof128.rs @@ -0,0 +1,172 @@ +//! Ascon-XOF128 extendable-output function (NIST SP 800-232 §5.2). +//! +//! Sponge mode over `Ascon-p[12]` with rate = 64 bits, capacity = 256 bits. Supports the streaming +//! absorb/squeeze API of SP 800-232 §5.4 (squeeze may be called repeatedly). + +use bouncycastle_core::errors::{HashError, SuspendableError}; +use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; +use bouncycastle_core::traits::{Algorithm, SecurityStrength, Suspendable, XOF}; +use bouncycastle_utils::secret::Secret; + +use crate::sponge::{RATE, Sponge}; + +/// Ascon-XOF128 as specified in NIST SP 800-232. +#[derive(Clone)] +pub struct AsconXof128 { + sponge: Sponge, +} + +impl AsconXof128 { + /// Creates a new Ascon-XOF128 instance. + pub fn new() -> Self { + // Precomputed state after the initialization permutation (SP 800-232 Table 12). + Self { + sponge: Sponge::from_state([ + 0xDA82CE768D9447EB, 0xCC7CE6C75F1EF969, 0xE7508FD780085631, 0x0EE0EA53416B58CC, + 0xE0547524DB6F0BDE, + ]), + } + } + + // Squeeze `output.len()` bytes of output. May be called multiple times; the first call ends the + // absorb phase by padding and absorbing the final block. Returns the number of bytes written. + fn squeeze_into(&mut self, output: &mut [u8]) -> usize { + let written = output.len(); + if !self.sponge.squeezing() { + self.sponge.pad_and_absorb(); + } + self.sponge.squeeze(output); + written + } +} + +impl Default for AsconXof128 { + fn default() -> Self { + Self::new() + } +} + +impl Algorithm for AsconXof128 { + const ALG_NAME: &'static str = "Ascon-XOF128"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl XOF for AsconXof128 { + fn hash_xof(mut self, data: &[u8], result_len: usize) -> Vec { + self.sponge.absorb(data); + let mut out = vec![0u8; result_len]; + self.squeeze_into(&mut out); + out + } + + fn hash_xof_out(mut self, data: &[u8], output: &mut [u8]) -> usize { + self.sponge.absorb(data); + self.squeeze_into(output) + } + + fn absorb(&mut self, data: &[u8]) -> Result<(), HashError> { + if self.sponge.squeezing() { + return Err(HashError::InvalidState( + "Ascon-XOF128 cannot absorb after squeezing has begun", + )); + } + self.sponge.absorb(data); + Ok(()) + } + + fn absorb_last_partial_byte( + &mut self, + _partial_byte: u8, + _num_partial_bits: usize, + ) -> Result<(), HashError> { + Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte input")) + } + + fn squeeze(&mut self, num_bytes: usize) -> Vec { + let mut out = vec![0u8; num_bytes]; + self.squeeze_into(&mut out); + out + } + + fn squeeze_out(&mut self, output: &mut [u8]) -> usize { + self.squeeze_into(output) + } + + fn squeeze_partial_byte_final(self, _num_bits: usize) -> Result { + Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte output")) + } + + fn squeeze_partial_byte_final_out( + self, + _num_bits: usize, + _output: &mut u8, + ) -> Result<(), HashError> { + Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte output")) + } + + fn max_security_strength(&self) -> SecurityStrength { + SecurityStrength::_128bit + } +} + +/// Length in bytes of the serialized state of [`AsconXof128`]. +/// Layout: 3-byte library version || 1-byte state tag || 40-byte sponge state (5 × u64 LE) +/// || 8-byte rate buffer || 1-byte buffer position || 1-byte squeezing flag. +pub const SUSPENDED_ASCON_XOF128_STATE_LEN: usize = 54; + +// Distinguishes an Ascon-XOF128 serialized state from the other (same-shaped) Ascon sponge states. +const XOF128_STATE_TAG: u8 = 0x02; + +impl Suspendable for AsconXof128 { + fn suspend(self) -> [u8; SUSPENDED_ASCON_XOF128_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_XOF128_STATE_LEN]; + // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_XOF128_STATE_LEN - 3 = 51 bytes. + let out: &mut [u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = XOF128_STATE_TAG; + let state = self.sponge.state_words(); + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); + } + out[41..49].copy_from_slice(&self.sponge.buf_bytes()); + debug_assert!(self.sponge.buf_pos() <= RATE); + out[49] = self.sponge.buf_pos() as u8; + out[50] = self.sponge.squeezing() as u8; + + out_to_return + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_XOF128_STATE_LEN], + ) -> Result { + // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_XOF128_STATE_LEN - 3 = 51 bytes. + let input: &[u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != XOF128_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + let mut s = Secret::<[u64; 5]>::new(); + for i in 0..5 { + // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. + s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + let mut buf = Secret::<[u8; RATE]>::new(); + buf.copy_from_slice(&input[41..49]); + let buf_pos = input[49] as usize; + let squeezing = match input[50] { + 0 => false, + 1 => true, + _ => return Err(SuspendableError::InvalidData), + }; + // While absorbing, buf_pos must be < RATE (a full buffer is drained immediately); once + // squeezing, buf_pos may equal RATE (meaning "no leftover squeezed byte buffered"). + let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; + if !valid_pos { + return Err(SuspendableError::InvalidData); + } + + Ok(AsconXof128 { sponge: Sponge::from_parts(s, buf, buf_pos, squeezing) }) + } +} diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs new file mode 100644 index 00000000..661aa6e9 --- /dev/null +++ b/crypto/ascon/src/lib.rs @@ -0,0 +1,137 @@ +//! Ascon-based lightweight cryptography (NIST SP 800-232). +//! +//! This crate implements the four Ascon functions standardized in NIST SP 800-232 (August 2025): +//! +//! - [`ascon_aead128::AsconAead128`] — Ascon-AEAD128 authenticated encryption (128-bit +//! key/nonce/tag, 128-bit single-key security). +//! - [`ascon_hash256::AsconHash256`] — Ascon-Hash256 hash function (256-bit digest, 128-bit +//! security). +//! - [`ascon_xof128::AsconXof128`] — Ascon-XOF128 extendable-output function. +//! - [`ascon_cxof128::AsconCXof128`] — Ascon-CXOF128 customized extendable-output function. +//! +//! # Usage Examples +//! +//! Hashing (one-shot and streaming): +//! ``` +//! use bouncycastle_ascon::ascon_hash256::AsconHash256; +//! use bouncycastle_core::traits::Hash; +//! +//! // One-shot: +//! let digest = AsconHash256::digest(b"hello world"); +//! assert_eq!(digest.len(), 32); +//! +//! // Streaming: +//! let mut h = AsconHash256::new(); +//! h.do_update(b"hello "); +//! h.do_update(b"world"); +//! let mut out = [0u8; 32]; +//! h.do_final_out(&mut out); +//! assert_eq!(out, digest); +//! ``` +//! +//! Authenticated encryption (one-shot): +//! ``` +//! use bouncycastle_ascon::ascon_aead128::AsconAead128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); +//! let nonce = [1u8; 16]; // MUST be unique per encryption under a given key +//! let ad = b"associated data"; +//! let plaintext = b"secret message"; +//! +//! let mut ct = vec![0u8; plaintext.len() + 16]; // ciphertext || 16-byte tag +//! let n = AsconAead128::encrypt(&key, &nonce, Some(ad), plaintext, &mut ct).unwrap(); +//! ct.truncate(n); +//! +//! let mut pt = vec![0u8; ct.len() - 16]; +//! let m = AsconAead128::decrypt(&key, &nonce, Some(ad), &ct, &mut pt).unwrap(); +//! pt.truncate(m); +//! assert_eq!(&pt, plaintext); +//! ``` +//! +//! Authenticated encryption (streaming, in place): +//! ``` +//! use bouncycastle_ascon::ascon_aead128::AsconAead128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); +//! let nonce = [1u8; 16]; +//! +//! let mut buf = *b"secret message!!"; // transformed in place +//! let mut enc = AsconAead128::new(&key, &nonce, Some(b"associated data"), true).unwrap(); +//! enc.do_encrypt_update(&mut buf); // now ciphertext +//! let tag = enc.do_encrypt_final(); +//! +//! let mut dec = AsconAead128::new(&key, &nonce, Some(b"associated data"), false).unwrap(); +//! dec.do_decrypt_update(&mut buf); // now plaintext again, but not yet authenticated +//! dec.do_decrypt_final(&tag).unwrap(); // now authenticated +//! assert_eq!(&buf, b"secret message!!"); +//! ``` +//! +//! Extendable output: +//! ``` +//! use bouncycastle_ascon::ascon_xof128::AsconXof128; +//! use bouncycastle_core::traits::XOF; +//! +//! let out = AsconXof128::new().hash_xof(b"input", 64); +//! assert_eq!(out.len(), 64); +//! ``` +//! +//! # Memory Usage +//! +//! Ascon is a lightweight, permutation-based design intended for constrained devices. The internal +//! permutation state is 320 bits (40 bytes), held as five `u64` words, shared by all four +//! functions. There are no heap allocations in the streaming/`*_out` APIs, and stack usage is +//! small and constant; consequently this crate has no dedicated `mem_usage_benches` harness. +//! +//! | Type | In-memory size (bytes) | Suspended state size (bytes) | +//! |------|-------------------------|-------------------------------| +//! | [`ascon_aead128::AsconAead128`] | 72 | [`ascon_aead128::SUSPENDED_ASCON_AEAD128_STATE_LEN`] (46) | +//! | [`ascon_hash256::AsconHash256`] | 64 | [`ascon_hash256::SUSPENDED_ASCON_HASH256_STATE_LEN`] (53) | +//! | [`ascon_xof128::AsconXof128`] | 64 | [`ascon_xof128::SUSPENDED_ASCON_XOF128_STATE_LEN`] (54) | +//! | [`ascon_cxof128::AsconCXof128`] | 64 | [`ascon_cxof128::SUSPENDED_ASCON_CXOF128_STATE_LEN`] (54) | +//! +//! "In-memory size" is `core::mem::size_of` on a 64-bit target. +//! +//! # Security Considerations +//! +//! - **Nonce uniqueness (SP 800-232 R3):** a (key, nonce) pair must never be reused for two +//! different Ascon-AEAD128 encryptions. Nonce reuse breaks confidentiality. +//! - **Tag length:** this crate always produces and verifies the full 128-bit tag. Truncated tags +//! (SP 800-232 §4.2.1) are not exposed. +//! - **No partial-byte input:** Ascon-Hash256, Ascon-XOF128 and Ascon-CXOF128 are byte-oriented; +//! their `do_final_partial_bits`/`do_final_partial_bits_out` (and the equivalent XOF methods) +//! always return `HashError::InvalidInput`, including when reached through `HashFactory`. A +//! caller that needs a partial-byte final block should reach for SHA-3, which supports one. +//! - **Decryption tag check failure:** a ciphertext decryption whose finalization returns +//! `Err(SymmetricCipherError::AEADTagCheckFailed)` must be treated as tampered, and the entire +//! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`] and the +//! `AEADCipher` trait impl) zeroize their output buffer before returning that +//! error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / +//! [`ascon_aead128::AsconAead128::do_decrypt_final`]) does not: plaintext bytes are necessarily +//! written to the caller's buffer *before* the tag can be checked, so an application streaming a +//! large plaintext must have a way to cancel the operation or transaction if finalization returns +//! an error. + +// `bouncycastle-core` still uses `Vec` internally (see the TODO at the top of +// crypto/core/src/lib.rs), which blocks this crate from being `#![no_std]` as long as it depends +// on core's `std`-gated APIs. +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +mod permutation; +mod sponge; + +pub mod ascon_aead128; +pub mod ascon_cxof128; +pub mod ascon_hash256; +pub mod ascon_xof128; + +/// Algorithm name for Ascon-AEAD128. +pub const ASCON_AEAD128_NAME: &str = "Ascon-AEAD128"; +/// Algorithm name for Ascon-Hash256. +pub const ASCON_HASH256_NAME: &str = "Ascon-Hash256"; +/// Algorithm name for Ascon-XOF128. +pub const ASCON_XOF128_NAME: &str = "Ascon-XOF128"; +/// Algorithm name for Ascon-CXOF128. +pub const ASCON_CXOF128_NAME: &str = "Ascon-CXOF128"; diff --git a/crypto/ascon/src/permutation.rs b/crypto/ascon/src/permutation.rs new file mode 100644 index 00000000..a373bb78 --- /dev/null +++ b/crypto/ascon/src/permutation.rs @@ -0,0 +1,138 @@ +//! The Ascon-p permutation family (NIST SP 800-232 §3), shared by all four functions in this +//! crate: Ascon-AEAD128 uses both `Ascon-p[12]` and `Ascon-p[8]`; Ascon-Hash256, Ascon-XOF128, and +//! Ascon-CXOF128 use only `Ascon-p[12]`. +//! +//! These also carry the little-endian load/store helpers, replacing the external `arrayref` +//! crate so that this crate carries no third-party runtime dependencies (per the project's +//! QUALITY_AND_STYLE rules). All callers pass slices that are at least 8 bytes long at the given +//! offset, so `copy_from_slice` is infallible by construction and no fallible conversion is +//! involved. + +/// Load the 8 bytes at `src[off..off + 8]` as a little-endian `u64`. +#[inline(always)] +pub(crate) fn load_u64_le(src: &[u8], off: usize) -> u64 { + let mut b = [0u8; 8]; + b.copy_from_slice(&src[off..off + 8]); + u64::from_le_bytes(b) +} + +/// Store `val` as little-endian into `dst[off..off + 8]`. +#[inline(always)] +pub(crate) fn store_u64_le(dst: &mut [u8], off: usize, val: u64) { + dst[off..off + 8].copy_from_slice(&val.to_le_bytes()); +} + +/// The 320-bit Ascon state (SP 800-232 §3.1 Eq. 2): five 64-bit words S0..S4. +pub(crate) type AsconState = [u64; 5]; + +// The constants const_0..const_15 used to derive the round constants of Ascon-p[r] +// (SP 800-232 Table 5). The round constant for round i (0 <= i <= r-1) of Ascon-p[r] is +// c_i = const_{16-r+i} (SP 800-232 §3.2 Eq. 3). +const ROUND_CONSTS: [u64; 16] = [ + 0x3c, 0x2d, 0x1e, 0x0f, 0xf0, 0xe1, 0xd2, 0xc3, 0xb4, 0xa5, 0x96, 0x87, 0x78, 0x69, 0x5a, 0x4b, +]; + +/// One round p = p_L ∘ p_S ∘ p_C (SP 800-232 §3.2–3.4 Eq. 1): the constant-addition layer p_C +/// (§3.2 Eq. 4), the substitution layer p_S (§3.3 Eqs. 6–7), and the linear diffusion layer p_L +/// (§3.4 Eqs. 8–12) are fused here in their bitsliced form. +#[inline(always)] +pub(crate) fn round(s: &mut AsconState, c: u64) { + let sx = s[2] ^ c; + let t0 = s[0] ^ s[1] ^ sx ^ s[3] ^ (s[1] & (s[0] ^ sx ^ s[4])); + let t1 = s[0] ^ sx ^ s[3] ^ s[4] ^ ((s[1] ^ sx) & (s[1] ^ s[3])); + let t2 = s[1] ^ sx ^ s[4] ^ (s[3] & s[4]); + let t3 = s[0] ^ s[1] ^ sx ^ ((!s[0]) & (s[3] ^ s[4])); + let t4 = s[1] ^ s[3] ^ s[4] ^ ((s[0] ^ s[4]) & s[1]); + s[0] = t0 ^ t0.rotate_right(19) ^ t0.rotate_right(28); + s[1] = t1 ^ t1.rotate_right(39) ^ t1.rotate_right(61); + s[2] = !(t2 ^ t2.rotate_right(1) ^ t2.rotate_right(6)); + s[3] = t3 ^ t3.rotate_right(10) ^ t3.rotate_right(17); + s[4] = t4 ^ t4.rotate_right(7) ^ t4.rotate_right(41); +} + +/// Ascon-p[12] (SP 800-232 §3.2 Eq. 3: c_i = const_{4+i} for i = 0..11, i.e. round constants +/// const_4..const_15 of Table 5). +#[inline(always)] +pub(crate) fn p12(s: &mut AsconState) { + for &c in &ROUND_CONSTS[4..16] { + round(s, c); + } +} + +/// Ascon-p[8] (SP 800-232 §3.2 Eq. 3: c_i = const_{8+i} for i = 0..7, i.e. round constants +/// const_8..const_15 of Table 5). +#[inline(always)] +pub(crate) fn p8(s: &mut AsconState) { + for &c in &ROUND_CONSTS[8..16] { + round(s, c); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // SP 800-232 Table 14: initial values (before the initialization permutation). + const HASH256_IV: u64 = 0x0000080100cc0002; + const XOF128_IV: u64 = 0x0000080000cc0003; + const CXOF128_IV: u64 = 0x0000080000cc0004; + + // Pins the permutation independently of the KAT sweeps: SP 800-232 Table 12 gives the state + // at the end of each function's initialization phase, i.e. Ascon-p[12](IV || 0^256). + #[test] + fn p12_matches_table_12_precomputed_states() { + let mut s: AsconState = [HASH256_IV, 0, 0, 0, 0]; + p12(&mut s); + assert_eq!( + s, + [ + 0x9b1e5494e934d681, 0x4bc3a01e333751d2, 0xae65396c6b34b81a, 0x3c7fd4a4d56a4db3, + 0x1a5c464906c5976d, + ] + ); + + let mut s: AsconState = [XOF128_IV, 0, 0, 0, 0]; + p12(&mut s); + assert_eq!( + s, + [ + 0xda82ce768d9447eb, 0xcc7ce6c75f1ef969, 0xe7508fd780085631, 0x0ee0ea53416b58cc, + 0xe0547524db6f0bde, + ] + ); + + let mut s: AsconState = [CXOF128_IV, 0, 0, 0, 0]; + p12(&mut s); + assert_eq!( + s, + [ + 0x675527c2a0e8de03, 0x43d12d7dc0377bbc, 0xe9901dec426e81b5, 0x2ab14907720780b6, + 0x8f3f1d02d432bc46, + ] + ); + } + + // Pins `AsconCXof128::new()`'s precomputed empty-customization state (see + // `ascon_cxof128.rs`) by recomputing it from the Table 12 CXOF128 state above, following + // SP 800-232 Algorithm 7 with |Z| = 0: XOR the length word Z_0 = int64(0) into S[0..63], + // Ascon-p[12], then XOR the pad-only last customization block (Eq. 77: pad(empty, 64) = + // 0x01 || 0^63, i.e. byte 0x01 loaded little-endian into S[0..63]) and Ascon-p[12] again. + #[test] + fn cxof128_empty_customization_state_matches_algorithm_7() { + let mut s: AsconState = [ + 0x675527c2a0e8de03, 0x43d12d7dc0377bbc, 0xe9901dec426e81b5, 0x2ab14907720780b6, + 0x8f3f1d02d432bc46, + ]; + s[0] ^= 0u64; // Z_0 = int64(|Z|) = int64(0) = 0 (a no-op XOR, spelled out for clarity) + p12(&mut s); + s[0] ^= 0x01u64; // pad(empty, 64) = 0x01 || 0^63, loaded little-endian + p12(&mut s); + assert_eq!( + s, + [ + 0x500cccc894e3c9e8, 0x5bed06f28f71248d, 0x3b03a0f930afd512, 0x112ef093aa5c698b, + 0x00c8356340a347f0, + ] + ); + } +} diff --git a/crypto/ascon/src/sponge.rs b/crypto/ascon/src/sponge.rs new file mode 100644 index 00000000..c1618b6d --- /dev/null +++ b/crypto/ascon/src/sponge.rs @@ -0,0 +1,189 @@ +//! The absorb/pad/squeeze sponge shared by Ascon-Hash256, Ascon-XOF128, and Ascon-CXOF128 +//! (NIST SP 800-232 §5): a 64-bit rate over `Ascon-p[12]`. Each of those three types holds one +//! [`Sponge`] and differs only in its initial state and (for Ascon-CXOF128) an extra +//! customization-string absorption performed before message absorption begins. + +use bouncycastle_utils::secret::Secret; + +use crate::permutation::{AsconState, load_u64_le, p12, store_u64_le}; + +/// Rate in bytes for the Hash256/XOF128/CXOF128 sponge (64 bits, per SP 800-232 §5). +pub(crate) const RATE: usize = 8; + +pub(crate) struct Sponge { + // 320-bit sponge state (five 64-bit words S0..S4). Wrapped in `Secret` so the working state + // -- which absorbs the message -- is scrubbed with volatile writes when dropped. + s: Secret, + // Rate buffer: partial input block while absorbing, or leftover squeezed bytes afterwards. + buf: Secret<[u8; RATE]>, + buf_pos: usize, + squeezing: bool, +} + +impl Sponge { + /// Construct a sponge already in the given state (typically a function's precomputed + /// post-initialization state, SP 800-232 Table 12), ready to absorb. + pub(crate) fn from_state(state: AsconState) -> Self { + let mut s: Secret = Secret::new(); + *s = state; + Self { s, buf: Secret::new(), buf_pos: 0, squeezing: false } + } + + /// Reconstruct a sponge from raw parts (used by `Suspendable::from_suspended`). + pub(crate) fn from_parts( + s: Secret, + buf: Secret<[u8; RATE]>, + buf_pos: usize, + squeezing: bool, + ) -> Self { + Self { s, buf, buf_pos, squeezing } + } + + pub(crate) fn state_words(&self) -> [u64; 5] { + *self.s + } + + pub(crate) fn buf_bytes(&self) -> [u8; RATE] { + *self.buf + } + + pub(crate) fn buf_pos(&self) -> usize { + self.buf_pos + } + + pub(crate) fn squeezing(&self) -> bool { + self.squeezing + } + + /// XOR `v` into the first state word. Used by Ascon-CXOF128 to absorb the customization + /// string's bit length (SP 800-232 §5.3 Eq. 75) before the length-prefixed customization + /// blocks are absorbed via [`Sponge::absorb`]. + pub(crate) fn xor_word0(&mut self, v: u64) { + self.s[0] ^= v; + } + + /// Apply `Ascon-p[12]` to the state directly. Used by Ascon-CXOF128 between customization + /// blocks (SP 800-232 Algorithm 7). + pub(crate) fn permute(&mut self) { + p12(&mut self.s); + } + + /// Reset the rate buffer to begin a fresh absorb phase. Used by Ascon-CXOF128 once the + /// customization string has been fully absorbed, before message absorption begins. + pub(crate) fn reset_buffer(&mut self) { + self.buf.fill(0); + self.buf_pos = 0; + } + + /// Absorb input data. Panics if called after squeezing has begun. + pub(crate) fn absorb(&mut self, input: &[u8]) { + if self.squeezing { + panic!("attempt to absorb while squeezing"); + } + + let available = RATE - self.buf_pos; + if input.len() < available { + self.buf[self.buf_pos..self.buf_pos + input.len()].copy_from_slice(input); + self.buf_pos += input.len(); + return; + } + + let mut input = input; + + if self.buf_pos > 0 { + self.buf[self.buf_pos..].copy_from_slice(&input[..available]); + self.s[0] ^= u64::from_le_bytes(*self.buf); + p12(&mut self.s); + input = &input[available..]; + } + + while input.len() >= RATE { + self.s[0] ^= load_u64_le(input, 0); + p12(&mut self.s); + input = &input[RATE..]; + } + + self.buf[..input.len()].copy_from_slice(input); + self.buf_pos = input.len(); + } + + // Pad the final absorbed block (SP 800-232 Appendix A.2, Algorithm 2) by XORing in the + // buffered bytes (masked to `buf_pos` bytes -- any stale bytes beyond that in `buf` are + // masked off) followed by the padding bit at byte position `buf_pos`. Deliberately does not + // permute: the permutation is folded into the first block of `squeeze()` below, since Ascon- + // Hash256's fixed 4-block output and Ascon-XOF128/CXOF128's streaming output both begin + // their squeeze phase with a permute-then-read (SP 800-232 Algorithms 5-7). + pub(crate) fn pad_and_absorb(&mut self) { + let final_bits = (self.buf_pos << 3) as u32; + let x = u64::from_le_bytes(*self.buf); + let mask = + if final_bits == 0 { 0u64 } else { 0x00FF_FFFF_FFFF_FFFF_u64 >> (56 - final_bits) }; + self.s[0] ^= x & mask; + self.s[0] ^= 0x01u64 << final_bits; + } + + /// Squeeze `output.len()` bytes. May be called multiple times; the first call must follow + /// [`Sponge::pad_and_absorb`] and ends the absorb phase. + pub(crate) fn squeeze(&mut self, output: &mut [u8]) { + let mut output = output; + + if !self.squeezing { + self.squeezing = true; + self.buf_pos = RATE; + } else if self.buf_pos < RATE { + let available = RATE - self.buf_pos; + if output.len() <= available { + let end_pos = self.buf_pos + output.len(); + output.copy_from_slice(&self.buf[self.buf_pos..end_pos]); + self.buf_pos = end_pos; + return; + } + + output[..available].copy_from_slice(&self.buf[self.buf_pos..]); + output = &mut output[available..]; + self.buf_pos = RATE; + } + + while output.len() >= RATE { + p12(&mut self.s); + store_u64_le(output, 0, self.s[0]); + output = &mut output[RATE..]; + } + + if !output.is_empty() { + p12(&mut self.s); + *self.buf = self.s[0].to_le_bytes(); + output.copy_from_slice(&self.buf[..output.len()]); + self.buf_pos = output.len(); + } + } +} + +impl Clone for Sponge { + fn clone(&self) -> Self { + Self { + s: self.s.clone(), + buf: self.buf.clone(), + buf_pos: self.buf_pos, + squeezing: self.squeezing, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // `xor_word0` cannot be exercised as an XOR (as opposed to e.g. an OR) via any published KAT: + // its only caller (Ascon-CXOF128's customization-length absorption) combines a bit_length + // value -- always a multiple of 8 -- with a state word whose low 3 bits happen to be the + // only ones set for every customization length actually covered by NIST's KAT file (max 32 + // bytes). Pin the arithmetic directly instead. + #[test] + fn xor_word0_is_xor_not_or() { + let mut sponge = Sponge::from_state([0b0000_0101, 0, 0, 0, 0]); + sponge.xor_word0(0b0000_0110); + // 0b101 ^ 0b110 = 0b011. An OR would give 0b111. + assert_eq!(sponge.state_words()[0], 0b0000_0011); + } +} diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs new file mode 100644 index 00000000..d9b06635 --- /dev/null +++ b/crypto/ascon/tests/aead128_tests.rs @@ -0,0 +1,768 @@ +//! Ascon-AEAD128 tests (NIST SP 800-232). +//! +//! - A small embedded set of NIST LWC known-answer vectors (always-on correctness, no external +//! repo required). The full sweep lives in `bc_test_data.rs`. +//! - Behavioral / contract tests (round-trips, streaming chunk-boundary equivalence, authentication +//! failures, determinism), driven through the inherent explicit-nonce API. +//! - The shared `AEADCipher` conformance framework (`core-test-framework`), which exercises the +//! generic `AEADCipher` trait surface with internally-generated nonces. + +use bouncycastle_ascon::ascon_aead128::{ + AsconAead128, AsconAead128Decryptor, AsconAead128Encryptor, +}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::SecurityStrength; +use bouncycastle_core_test_framework::symmetric_ciphers::{ + TestFrameworkAEADCipher, TestFrameworkSimpleCipher, +}; +use bouncycastle_hex as hex; + +// All embedded vectors use this fixed key/nonce (the NIST LWC KAT convention). +const KEY: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, +]; +const NONCE: [u8; 16] = [ + 0x0F, 0x0E, 0x0D, 0x0C, 0x0B, 0x0A, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00, +]; + +const PT_SIZES: [usize; 10] = [0, 1, 15, 16, 17, 31, 32, 33, 64, 100]; +const CHUNK_SIZES: [usize; 6] = [1, 3, 7, 13, 16, 17]; + +/// Embedded NIST LWC Ascon-AEAD128 vectors `(plaintext, associated_data, ciphertext||tag)` in hex. +/// Key = Nonce = 000102…0F. Spans empty input, AD-only (incl. a full 32-byte AD block), partial PT +/// with AD, and a multi-block plaintext. (Counts 1, 2, 5, 33, 68, 69, 153, 1057 of +/// LWC_AEAD_KAT_128_128.txt.) +const AEAD_KAT: &[(&str, &str, &str)] = &[ + ("", "", "4427D64B8E1E1451FC445960F0839BB0"), + ("", "00", "103AB79D913A0321287715A979BB8585"), + ("", "00010203", "C6FF3CF70575B144B955820D9BC7685E"), + ( + "", + "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "22133A313FBF0B38029A45870AADC542", + ), + ("0001", "00", "25FB41D2732019820A0F8BAB4248B35E7B0B"), + ("0001", "0001", "49E57017A30E8073D1FA284AC8346110F89F"), + ( + "00010203", + "000102030405060708090A0B0C0D0E0F10111213", + "C305EB0E9A9A7833C5F6FB36BD82F1C78C322678", + ), + ( + "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "", + "E770D289D2A44AEE7CD0A48ECE5274E381BAD7E163DCC4970F7873610DEBBEB1A28657F6E82FE53D08B09EFF9330BD2B", + ), +]; + +fn dh(s: &str) -> Vec { + let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } +} + +fn ad_opt(ad: &[u8]) -> Option<&[u8]> { + if ad.is_empty() { None } else { Some(ad) } +} + +fn pattern(len: usize) -> Vec { + (0..len).map(|i| (i as u8).wrapping_mul(7).wrapping_add(1)).collect() +} + +/// Build a `KeyMaterial<16>` suitable for `AsconAead128`. The NIST LWC KAT vectors include an +/// all-zero key (Count=1), which `KeyMaterial::from_bytes_as_type` would otherwise tag +/// `KeyType::Zeroized` / `SecurityStrength::None`; force the type/strength the way a caller who +/// knows the provenance of the key would (see `cli/src/helpers.rs::parse_seed`). +fn key_material(key: &[u8; 16]) -> KeyMaterial<16> { + let mut km = KeyMaterial::<16>::from_bytes_as_type(key, KeyType::SymmetricCipherKey).unwrap(); + do_hazardous_operations(&mut km, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::_128bit) + }) + .unwrap(); + km +} + +fn enc_oneshot(key: &[u8; 16], nonce: &[u8; 16], ad: &[u8], pt: &[u8]) -> Vec { + let km = key_material(key); + let mut out = vec![0u8; pt.len() + 16]; + let n = AsconAead128::encrypt(&km, nonce, ad_opt(ad), pt, &mut out).unwrap(); + out.truncate(n); + out +} + +fn dec_oneshot( + key: &[u8; 16], + nonce: &[u8; 16], + ad: &[u8], + ct: &[u8], +) -> Result, SymmetricCipherError> { + let km = key_material(key); + let mut out = vec![0u8; ct.len()]; + let n = AsconAead128::decrypt(&km, nonce, ad_opt(ad), ct, &mut out)?; + out.truncate(n); + Ok(out) +} + +fn enc_chunked(key: &[u8; 16], nonce: &[u8; 16], ad: &[u8], pt: &[u8], chunk: usize) -> Vec { + let km = key_material(key); + let mut cipher = AsconAead128::new(&km, nonce, ad_opt(ad), true).unwrap(); + let mut out = vec![0u8; pt.len() + 16]; + out[..pt.len()].copy_from_slice(pt); + + let chunk = chunk.max(1); + let mut off = 0; + while off < pt.len() { + let end = (off + chunk).min(pt.len()); + cipher.do_encrypt_update(&mut out[off..end]); + off = end; + } + let tag = cipher.do_encrypt_final(); + out[pt.len()..].copy_from_slice(&tag); + out +} + +fn dec_chunked( + key: &[u8; 16], + nonce: &[u8; 16], + ad: &[u8], + ct: &[u8], + chunk: usize, +) -> Result, SymmetricCipherError> { + let km = key_material(key); + let mut cipher = AsconAead128::new(&km, nonce, ad_opt(ad), false).unwrap(); + let pt_len = ct.len() - 16; + let mut out = vec![0u8; pt_len]; + out.copy_from_slice(&ct[..pt_len]); + + let chunk = chunk.max(1); + let mut off = 0; + while off < pt_len { + let end = (off + chunk).min(pt_len); + cipher.do_decrypt_update(&mut out[off..end]); + off = end; + } + // infallible: ct.len() - pt_len == 16 by construction above. + let tag: [u8; 16] = ct[pt_len..].try_into().unwrap(); + cipher.do_decrypt_final(&tag)?; + Ok(out) +} + +/* -------------------------------------------------------------------------- */ +/* Embedded known-answer vectors */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead128_embedded_kat() { + // The NIST LWC AEAD KAT convention uses Key == Nonce == 000102…0F (i.e. KEY for both). + let kat_nonce = KEY; + for (pt_hex, ad_hex, ct_hex) in AEAD_KAT { + let pt = dh(pt_hex); + let ad = dh(ad_hex); + let expected_ct = dh(ct_hex); + + let got_ct = enc_oneshot(&KEY, &kat_nonce, &ad, &pt); + assert_eq!(got_ct, expected_ct, "encrypt mismatch for PT={pt_hex} AD={ad_hex}"); + + let got_pt = + dec_oneshot(&KEY, &kat_nonce, &ad, &expected_ct).expect("decrypt should succeed"); + assert_eq!(got_pt, pt, "decrypt mismatch for CT={ct_hex}"); + } +} + +/* -------------------------------------------------------------------------- */ +/* Round-trips and AAD handling */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead_round_trip_sizes_and_ad() { + for &pt_len in PT_SIZES.iter() { + let pt = pattern(pt_len); + for ad in [Vec::new(), b"associated-data".to_vec(), pattern(40)] { + let ct = enc_oneshot(&KEY, &NONCE, &ad, &pt); + assert_eq!(ct.len(), pt_len + 16, "ciphertext = plaintext || 16-byte tag"); + let recovered = dec_oneshot(&KEY, &NONCE, &ad, &ct).expect("decrypt should succeed"); + assert_eq!(recovered, pt, "round-trip mismatch (pt_len={pt_len}, ad_len={})", ad.len()); + } + } +} + +#[test] +fn aead_aad_only_round_trip() { + // Empty plaintext, non-empty AD: ciphertext is just the 16-byte tag. + let ad = b"only-associated-data"; + let ct = enc_oneshot(&KEY, &NONCE, ad, b""); + assert_eq!(ct.len(), 16); + let recovered = dec_oneshot(&KEY, &NONCE, ad, &ct).expect("decrypt should succeed"); + assert!(recovered.is_empty()); +} + +/* -------------------------------------------------------------------------- */ +/* Streaming chunk-boundary equivalence */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead_streaming_matches_one_shot() { + for &pt_len in PT_SIZES.iter() { + let pt = pattern(pt_len); + let ad = pattern(20); + let ct_ref = enc_oneshot(&KEY, &NONCE, &ad, &pt); + + for &chunk in CHUNK_SIZES.iter() { + let ct = enc_chunked(&KEY, &NONCE, &ad, &pt, chunk); + assert_eq!(ct, ct_ref, "chunked encrypt mismatch (pt_len={pt_len}, chunk={chunk})"); + + let pt_back = dec_chunked(&KEY, &NONCE, &ad, &ct_ref, chunk) + .expect("chunked decrypt should pass"); + assert_eq!(pt_back, pt, "chunked decrypt mismatch (pt_len={pt_len}, chunk={chunk})"); + } + } +} + +#[test] +fn aead_chunked_aad_matches_one_shot() { + let pt = pattern(30); + let ad = pattern(40); + let ct_ref = enc_oneshot(&KEY, &NONCE, &ad, &pt); + let km = key_material(&KEY); + + for &chunk in CHUNK_SIZES.iter() { + let mut e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + for piece in ad.chunks(chunk) { + e.do_update_aad(piece).unwrap(); + } + let mut out = vec![0u8; pt.len() + 16]; + out[..pt.len()].copy_from_slice(&pt); + e.do_encrypt_update(&mut out[..pt.len()]); + let tag = e.do_encrypt_final(); + out[pt.len()..].copy_from_slice(&tag); + assert_eq!(out, ct_ref, "chunked AAD mismatch (chunk={chunk})"); + } +} + +/* -------------------------------------------------------------------------- */ +/* Trait-driven streaming sweep (this is what would have caught F1/F2) */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead_trait_streaming_sweep() { + use bouncycastle_core::traits::AEADCipher; + + let km = key_material(&KEY); + for pt_len in 0..=40 { + let pt = pattern(pt_len); + for ad_len in [0, 1, 15, 16, 17, 33] { + let ad = pattern(ad_len); + let ad_opt_ = ad_opt(&ad); + let ct_ref = enc_oneshot(&KEY, &NONCE, &ad, &pt); + let (ct_ref_body, tag_ref) = ct_ref.split_at(pt_len); + + for &chunk in [1, 2, 7, 15, 16, 17, 31, 32, 1024].iter() { + let mut e = AsconAead128::new(&km, &NONCE, ad_opt_, true).unwrap(); + let mut out = pt.clone(); + let chunk = chunk.max(1); + let mut off = 0; + while off < out.len() { + let end = (off + chunk).min(out.len()); + e.do_encrypt_update(&mut out[off..end]); + off = end; + } + let tag = e.do_aead_encrypt_final().unwrap(); + assert_eq!(out, ct_ref_body, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); + assert_eq!(tag, tag_ref, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); + + let mut d = AsconAead128::new(&km, &NONCE, ad_opt_, false).unwrap(); + let mut back = ct_ref_body.to_vec(); + let mut off = 0; + while off < back.len() { + let end = (off + chunk).min(back.len()); + d.do_decrypt_update(&mut back[off..end]); + off = end; + } + let tag_arr: [u8; 16] = tag_ref.try_into().unwrap(); + d.do_aead_decrypt_final(&tag_arr).unwrap(); + assert_eq!(back, pt, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); + } + } + } +} + +#[test] +fn do_aead_decrypt_final_rejects_wrong_tag() { + use bouncycastle_core::traits::AEADCipher; + + let km = key_material(&KEY); + let pt = pattern(20); + let mut d = AsconAead128::new(&km, &NONCE, None, false).unwrap(); + let mut buf = pt.clone(); + d.do_decrypt_update(&mut buf); + let wrong_tag = [0xFFu8; 16]; + assert!(matches!( + d.do_aead_decrypt_final(&wrong_tag), + Err(SymmetricCipherError::AEADTagCheckFailed) + )); +} + +/* -------------------------------------------------------------------------- */ +/* std-only Vec-returning trait wrappers */ +/* -------------------------------------------------------------------------- */ + +// `TestFrameworkAEADCipher` only exercises the `_out` (buffer-based) +// entry points, so the `#[cfg(feature = "std")]` `Vec`-returning wrappers (`encrypt`, `decrypt`, +// `aead_encrypt`, `aead_decrypt`) are otherwise never called by any test. +#[test] +fn aead128_std_vec_wrappers_round_trip() { + use bouncycastle_core::traits::AEADCipher; + + let km = key_material(&KEY); + let msg = pattern(40); + + let (nonce, ct) = >::encrypt(&km, &msg).unwrap(); + assert_eq!(ct.len(), msg.len() + 16); + let pt = >::decrypt(&km, nonce, &ct).unwrap(); + assert_eq!(pt, msg); + + let (nonce, ct, tag) = + >::aead_encrypt(&km, b"aad", &msg).unwrap(); + assert_eq!(ct.len(), msg.len()); + let pt = >::aead_decrypt(&km, &nonce, b"aad", &ct, &tag) + .unwrap(); + assert_eq!(pt, msg); + + // Tampering must still be rejected through these entry points too. + assert!( + >::aead_decrypt( + &km, &nonce, b"wrong-aad", &ct, &tag + ) + .is_err() + ); +} + +// None of the length checks in the `AEADCipher` `_out` entry points are ever +// triggered by `TestFrameworkAEADCipher` (which always pass a +// generously-sized fixed buffer), nor by the inherent one-shot `encrypt`/`decrypt` tests above +// (which always size their own buffer correctly). Exercise every one directly. +#[test] +fn aead128_undersized_buffers_are_rejected() { + use bouncycastle_core::traits::AEADCipher; + + let km = key_material(&KEY); + let msg = pattern(40); + + // AEADCipher::encrypt_out: ciphertext buffer shorter than plaintext.len() + 16. + let mut too_small = vec![0u8; msg.len() + 15]; + match >::encrypt_out(&km, &msg, &mut too_small) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { + assert_eq!(needed, msg.len() + 16); + } + other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), + } + + // AEADCipher::decrypt / decrypt_out: ciphertext shorter than the 16-byte tag. + let short = [0u8; 8]; + match >::decrypt(&km, NONCE, &short) { + Err(SymmetricCipherError::GenericError(_)) => {} + other => panic!("expected GenericError, got {other:?}"), + } + let mut pt_buf = [0u8; 8]; + match >::decrypt_out(&km, NONCE, &short, &mut pt_buf) { + Err(SymmetricCipherError::GenericError(_)) => {} + other => panic!("expected GenericError, got {other:?}"), + } + + // AEADCipher::decrypt_out: valid-length ciphertext, but undersized plaintext buffer. + let ct = enc_oneshot(&KEY, &NONCE, &[], &msg); + let mut too_small_pt = vec![0u8; msg.len() - 1]; + match >::decrypt_out(&km, NONCE, &ct, &mut too_small_pt) + { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { + assert_eq!(needed, msg.len()); + } + other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), + } + + // decrypt / decrypt_out: ciphertext of exactly 16 bytes (an empty plaintext plus the tag) is + // the boundary case and must NOT be rejected as "too short". + let empty_ct = enc_oneshot(&KEY, &NONCE, &[], &[]); + assert_eq!(empty_ct.len(), 16); + assert_eq!( + >::decrypt(&km, NONCE, &empty_ct).unwrap(), + Vec::::new() + ); + let mut empty_pt_buf = [0u8; 0]; + assert_eq!( + >::decrypt_out( + &km, NONCE, &empty_ct, &mut empty_pt_buf + ) + .unwrap(), + 0 + ); + + // decrypt_out: a plaintext buffer *larger* than needed must succeed, not be rejected. + let mut oversized_pt = vec![0xAAu8; msg.len() + 5]; + let n = + >::decrypt_out(&km, NONCE, &ct, &mut oversized_pt) + .unwrap(); + assert_eq!(n, msg.len()); + assert_eq!(&oversized_pt[..n], &msg[..]); + + // AEADCipher::aead_encrypt_out: ciphertext buffer shorter than the plaintext. + let mut too_small = vec![0u8; msg.len() - 1]; + match >::aead_encrypt_out( + &km, b"aad", &msg, &mut too_small, + ) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { + assert_eq!(needed, msg.len()); + } + other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), + } + + // AEADCipher::aead_decrypt_out: plaintext buffer shorter than the ciphertext. + let (nonce, ct, tag) = + >::aead_encrypt(&km, b"aad", &msg).unwrap(); + let mut too_small_pt = vec![0u8; ct.len() - 1]; + match >::aead_decrypt_out( + &km, &nonce, b"aad", &ct, &tag, &mut too_small_pt, + ) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { + assert_eq!(needed, ct.len()); + } + other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), + } +} + +// The plain (non-AEAD) view's `decrypt`/`decrypt_out` report an authentication failure as +// `DecryptionFailed`, not `AEADTagCheckFailed` (see the comment on `AsconAead128`'s +// `AEADCipher::decrypt_out` impl): this view has no separate tag to name, and the trait's own doc +// comment says every implementor reports it this way. A mutant deleting that remapping would +// otherwise survive, since nothing else in this file calls the plain view on a tampered +// ciphertext. +#[test] +fn aead128_plain_view_reports_tamper_as_decryption_failed() { + use bouncycastle_core::traits::AEADCipher; + + let km = key_material(&KEY); + let msg = pattern(40); + let ct = enc_oneshot(&KEY, &NONCE, &[], &msg); + + let mut tampered = ct.clone(); + tampered[0] ^= 0x01; + + match >::decrypt(&km, NONCE, &tampered) { + Err(SymmetricCipherError::DecryptionFailed) => {} + other => panic!("expected DecryptionFailed, got {other:?}"), + } + + let mut pt_buf = vec![0u8; msg.len()]; + match >::decrypt_out(&km, NONCE, &tampered, &mut pt_buf) + { + Err(SymmetricCipherError::DecryptionFailed) => {} + other => panic!("expected DecryptionFailed, got {other:?}"), + } +} + +/* -------------------------------------------------------------------------- */ +/* Authentication failures */ +/* -------------------------------------------------------------------------- */ + +fn assert_auth_failed(result: Result, SymmetricCipherError>, ctx: &str) { + match result { + Err(SymmetricCipherError::AEADTagCheckFailed) => {} + other => panic!("{ctx}: expected AEADTagCheckFailed, got {other:?}"), + } +} + +#[test] +fn aead_rejects_tampering() { + let pt = pattern(50); + let ad = b"the-aad"; + let ct = enc_oneshot(&KEY, &NONCE, ad, &pt); + + // Wrong key. + let mut bad_key = KEY; + bad_key[0] ^= 0x01; + assert_auth_failed(dec_oneshot(&bad_key, &NONCE, ad, &ct), "wrong key"); + + // Wrong nonce. + let mut bad_nonce = NONCE; + bad_nonce[3] ^= 0x80; + assert_auth_failed(dec_oneshot(&KEY, &bad_nonce, ad, &ct), "wrong nonce"); + + // Modified associated data. + assert_auth_failed(dec_oneshot(&KEY, &NONCE, b"the-AAD", &ct), "modified ad"); + + // Flipped tag byte (last byte). + let mut tag_flip = ct.clone(); + let last = tag_flip.len() - 1; + tag_flip[last] ^= 0x01; + assert_auth_failed(dec_oneshot(&KEY, &NONCE, ad, &tag_flip), "flipped tag"); + + // Flipped ciphertext body byte. + let mut body_flip = ct.clone(); + body_flip[0] ^= 0x01; + assert_auth_failed(dec_oneshot(&KEY, &NONCE, ad, &body_flip), "flipped body"); +} + +#[test] +fn aead_tamper_leaves_no_plaintext_in_output_buffer() { + let pt = pattern(20); + let ad = b"ctx"; + let ct = enc_oneshot(&KEY, &NONCE, ad, &pt); + let mut tampered = ct.clone(); + tampered[0] ^= 0x01; + + let km = key_material(&KEY); + let mut out = vec![0xAAu8; pt.len()]; + let n = AsconAead128::decrypt(&km, &NONCE, ad_opt(ad), &tampered, &mut out); + assert!(matches!(n, Err(SymmetricCipherError::AEADTagCheckFailed))); + assert!(out.iter().all(|&b| b == 0), "output buffer must be zeroized on tag failure"); +} + +#[test] +fn aead_short_ciphertext_is_error() { + let short = [0u8; 8]; // shorter than the 16-byte tag + let km = key_material(&KEY); + let mut out = [0u8; 16]; + match AsconAead128::decrypt(&km, &NONCE, None, &short, &mut out) { + Err(SymmetricCipherError::GenericError(_)) => {} + other => panic!("expected GenericError for short ciphertext, got {other:?}"), + } +} + +/* -------------------------------------------------------------------------- */ +/* Determinism / nonce sensitivity / Debug mask */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead_is_deterministic_and_nonce_sensitive() { + let pt = pattern(40); + let ad = b"ctx"; + let a = enc_oneshot(&KEY, &NONCE, ad, &pt); + let b = enc_oneshot(&KEY, &NONCE, ad, &pt); + assert_eq!(a, b, "same (key,nonce,ad,pt) must yield identical (ct,tag)"); + + let mut other_nonce = NONCE; + other_nonce[0] ^= 0x01; + let c = enc_oneshot(&KEY, &other_nonce, ad, &pt); + assert_ne!(a, c, "changing the nonce must change the ciphertext (SP 800-232 R3)"); +} + +#[test] +fn aead_debug_display_are_masked() { + let km = key_material(&KEY); + let e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + assert!(format!("{e:?}").contains("masked")); + assert!(format!("{e}").contains("masked")); +} + +/* -------------------------------------------------------------------------- */ +/* Direction-misuse guards */ +/* -------------------------------------------------------------------------- */ + +#[test] +#[should_panic(expected = "decryptor")] +fn do_encrypt_update_on_decryptor_panics() { + let km = key_material(&KEY); + let mut d = AsconAead128::new(&km, &NONCE, None, false).unwrap(); + let mut buf = [0u8; 4]; + d.do_encrypt_update(&mut buf); +} + +#[test] +#[should_panic(expected = "encryptor")] +fn do_decrypt_update_on_encryptor_panics() { + let km = key_material(&KEY); + let mut e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + let mut buf = [0u8; 4]; + e.do_decrypt_update(&mut buf); +} + +/* -------------------------------------------------------------------------- */ +/* AEADCipher trait conformance (shared core-test-framework) */ +/* -------------------------------------------------------------------------- */ + +#[test] +fn aead128_trait_framework() { + // Exercises the generic AEADCipher<16,16,16> surface: internally + // generated (random, distinct) nonces, key-type / key-strength enforcement, and the AEAD + // tamper-detection contract (modified ciphertext / AAD / tag must fail the tag check, and + // must never leave plaintext in the output buffer). + TestFrameworkAEADCipher::new().test::<16, 16, 16, AsconAead128>(); +} + +/// Exercises [`AEADCipherEncryptor`]/[`AEADCipherDecryptor`], the streaming pair +/// [`AsconAead128Encryptor`]/[`AsconAead128Decryptor`] adapt [`AsconAead128`] to: `update_out_len` +/// correctness, chunking-independence of both AAD and data, the AAD-after-data `StateError`, and +/// tamper detection, all against the generic conformance suite rather than hand-written here. +/// +/// [`AEADCipherEncryptor`]: bouncycastle_core::traits::AEADCipherEncryptor +/// [`AEADCipherDecryptor`]: bouncycastle_core::traits::AEADCipherDecryptor +#[test] +fn aead128_encryptor_decryptor_trait_framework() { + TestFrameworkAEADCipher::new() + .test_encryptor_decryptor::<16, 16, 16, 0, AsconAead128Encryptor, AsconAead128Decryptor>(); +} + +/// The inline-tag adapter ([`TaggedEncryptor`]/[`TaggedDecryptor`]) over the same +/// [`AsconAead128Encryptor`]/[`AsconAead128Decryptor`] pair must pass the unrelated +/// [`SimpleCipherEncryptor`]/[`SimpleCipherDecryptor`] conformance suite -- proof that adapting an +/// AEAD to the `ciphertext || tag` layout costs nothing beyond appending the tag. +/// +/// [`TaggedEncryptor`]: bouncycastle_core::tagged_aead::TaggedEncryptor +/// [`TaggedDecryptor`]: bouncycastle_core::tagged_aead::TaggedDecryptor +/// [`SimpleCipherEncryptor`]: bouncycastle_core::traits::SimpleCipherEncryptor +/// [`SimpleCipherDecryptor`]: bouncycastle_core::traits::SimpleCipherDecryptor +#[test] +fn aead128_tagged_adapter_passes_simple_cipher_framework() { + use bouncycastle_core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; + + TestFrameworkSimpleCipher::new().test_encryptor_decryptor::< + 16, + 16, + 16, + TaggedEncryptor, + TaggedDecryptor, + >(); +} + +/// The two tag layouts must agree byte for byte: `direct_ciphertext || direct_tag`, produced by +/// streaming [`AsconAead128Encryptor`] directly, must equal what streaming through +/// [`TaggedEncryptor`] gives for the same key, nonce (driven by the same RNG stream), AAD and +/// message -- and the reverse must decrypt either back to the original plaintext. +/// +/// [`TaggedEncryptor`]: bouncycastle_core::tagged_aead::TaggedEncryptor +#[test] +fn aead128_tagged_and_direct_layouts_agree() { + use bouncycastle_core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; + use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, SimpleCipherDecryptor, SimpleCipherEncryptor, + }; + use bouncycastle_core_test_framework::FixedSeedRNG; + + let km = key_material(&KEY); + let aad = b"tagged-adapter-aad"; + for pt_len in [0usize, 1, 15, 16, 17, 40] { + let pt = pattern(pt_len); + let pinned = [0x11u8; 16]; + + let (mut direct_enc, direct_nonce) = + AsconAead128Encryptor::do_encrypt_init_rng(&km, &mut FixedSeedRNG::<16>::new(pinned)) + .unwrap(); + direct_enc.do_update_aad(aad).unwrap(); + let mut direct_ct = vec![0u8; pt.len()]; + direct_enc.do_update_out(&pt, &mut direct_ct).unwrap(); + let mut nothing = [0u8; 0]; + let (_flushed, direct_tag) = direct_enc.do_encrypt_final(&mut nothing).unwrap(); + let mut direct_inline = direct_ct.clone(); + direct_inline.extend_from_slice(&direct_tag); + + let (mut tagged_enc, tagged_nonce) = + as SimpleCipherEncryptor<16, 16, 16>>::do_encrypt_init_rng( + &km, + &mut FixedSeedRNG::<16>::new(pinned), + ) + .unwrap(); + tagged_enc.do_update_aad::<16, 16, 16>(aad).unwrap(); + let mut tagged_out = vec![0u8; pt.len() + 16]; + let written = tagged_enc.do_update_out(&pt, &mut tagged_out).unwrap(); + let mut last = [0u8; 16]; + let last_len = as SimpleCipherEncryptor< + 16, + 16, + 16, + >>::do_final_out(tagged_enc, &mut last) + .unwrap(); + tagged_out[written..written + last_len].copy_from_slice(&last[..last_len]); + tagged_out.truncate(written + last_len); + + assert_eq!(direct_nonce, tagged_nonce, "pt_len {pt_len}: same RNG stream, same nonce"); + assert_eq!(direct_inline, tagged_out, "pt_len {pt_len}: inline layout must agree"); + + // ...and both decrypt back to the original plaintext, each through its own view. + let mut direct_dec = AsconAead128Decryptor::do_decrypt_init(&km, &direct_nonce).unwrap(); + direct_dec.do_update_aad(aad).unwrap(); + let mut direct_pt = vec![0u8; direct_ct.len()]; + direct_dec.do_update_out(&direct_ct, &mut direct_pt).unwrap(); + let tag_arr: [u8; 16] = direct_tag; + direct_dec.do_decrypt_final(&tag_arr, &mut nothing).unwrap(); + assert_eq!(direct_pt, pt, "pt_len {pt_len}: direct decrypt round trip"); + + let mut tagged_dec = as SimpleCipherDecryptor< + 16, + 16, + 16, + >>::do_decrypt_init(&km, &tagged_nonce) + .unwrap(); + tagged_dec.do_update_aad::<16, 16>(aad).unwrap(); + let mut tagged_pt = vec![0u8; tagged_out.len()]; + let written = tagged_dec.do_update_out(&tagged_out, &mut tagged_pt).unwrap(); + let (_, final_data_len) = tagged_dec.do_final().unwrap(); + tagged_pt.truncate(written + final_data_len); + assert_eq!(tagged_pt, pt, "pt_len {pt_len}: tagged decrypt round trip"); + } +} + +#[test] +fn aead128_suspendable_keyed_state() { + use bouncycastle_core::errors::SuspendableError; + use bouncycastle_core::traits::SuspendableKeyed; + use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableKeyedState; + + let pt = pattern(40); + let ad = b"suspend-ad"; + let ct_ref = enc_oneshot(&KEY, &NONCE, ad, &pt); + let km = key_material(&KEY); + + // Encrypt part of the plaintext, suspend, resume with the re-supplied key, finish, and confirm + // the output matches a one-shot encryption. The key is never part of the serialized state. + let mut e = AsconAead128::new(&km, &NONCE, Some(ad), true).unwrap(); + let mut out = vec![0u8; pt.len() + 16]; + out[..pt.len()].copy_from_slice(&pt); + e.do_encrypt_update(&mut out[..18]); + + TestFrameworkSuspendableKeyedState::new().test(&e, &km); + + let serialized = e.clone().suspend(); + let mut resumed = AsconAead128::from_suspended(serialized, &km).unwrap(); + resumed.do_encrypt_update(&mut out[18..pt.len()]); + let tag = resumed.do_encrypt_final(); + out[pt.len()..].copy_from_slice(&tag); + assert_eq!(out, ct_ref, "resumed AEAD ciphertext must match one-shot encryption"); + + // A corrupted state tag must be rejected (the tag is the byte after the 3-byte version prefix). + let mut busted = serialized; + busted[3] ^= 0xFF; + assert!(matches!( + AsconAead128::from_suspended(busted, &km), + Err(SuspendableError::InvalidData) + )); + + // An unknown call-state discriminant must be rejected. + let last = serialized.len() - 1; + let pos_offset = serialized.len() - 2; + let mut bad_state = serialized; + bad_state[last] = 200; + assert!(matches!( + AsconAead128::from_suspended(bad_state, &km), + Err(SuspendableError::InvalidData) + )); + + // A nonzero byte position while still in an *Init state must be rejected. + let mut inconsistent = serialized; + inconsistent[pos_offset] = 3; // pos = 3 + inconsistent[last] = 0; // EncInit + assert!(matches!( + AsconAead128::from_suspended(inconsistent, &km), + Err(SuspendableError::InvalidData) + )); + + // pos >= RATE (16) must be rejected. + let mut bad_pos = serialized; + bad_pos[pos_offset] = 16; + assert!(matches!( + AsconAead128::from_suspended(bad_pos, &km), + Err(SuspendableError::InvalidData) + )); +} diff --git a/crypto/ascon/tests/bc_test_data.rs b/crypto/ascon/tests/bc_test_data.rs new file mode 100644 index 00000000..01525a94 --- /dev/null +++ b/crypto/ascon/tests/bc_test_data.rs @@ -0,0 +1,242 @@ +//! Test against the bc-test-data repo. +//! Requires that the bc-test-data repository is cloned and available for testing at +//! "../bc-test-data" relative to the root of this git project (or "../../../bc-test-data" relative +//! to this crate). When the repo is absent these tests print a warning and are skipped. +//! +//! The NIST SP 800-232 ASCON known-answer test (KAT) vectors live under +//! `bc-test-data/crypto/ascon//`. These full sweeps (1025–1089 cases each) complement the +//! small embedded vector sets in the per-primitive test files. + +#[cfg(test)] +mod bc_test_data { + use bouncycastle_ascon::ascon_aead128::AsconAead128; + use bouncycastle_ascon::ascon_cxof128::AsconCXof128; + use bouncycastle_ascon::ascon_hash256::AsconHash256; + use bouncycastle_ascon::ascon_xof128::AsconXof128; + use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, + }; + use bouncycastle_core::traits::{SecurityStrength, XOF}; + use bouncycastle_hex as hex; + use std::collections::BTreeMap; + use std::fs; + use std::path::Path; + use std::sync::Once; + + const TEST_DATA_PATH_RELATIVE: &str = "../../../bc-test-data/crypto/ascon"; + const TEST_DATA_PATH: &str = "../bc-test-data/crypto/ascon"; + + static TEST_DATA_CHECK: Once = Once::new(); + + fn get_test_data(filename: &str) -> Result { + let found: u8; + if Path::new(TEST_DATA_PATH_RELATIVE).exists() { + found = 1; + } else if Path::new(TEST_DATA_PATH).exists() { + found = 2; + } else { + found = 3; + }; + + // just print once + TEST_DATA_CHECK.call_once(|| match found { + 1 => println!("bc-test-data found at: {:?}", TEST_DATA_PATH_RELATIVE), + 2 => println!("bc-test-data found at: {:?}", TEST_DATA_PATH), + _ => println!("WARNING: bc-test-data directory not found; tests will be skipped"), + }); + + let contents = if Path::new(TEST_DATA_PATH_RELATIVE).exists() { + fs::read_to_string(TEST_DATA_PATH_RELATIVE.to_string() + "/" + filename).unwrap() + } else if Path::new(TEST_DATA_PATH).exists() { + fs::read_to_string(TEST_DATA_PATH.to_string() + "/" + filename).unwrap() + } else { + return Err(()); + }; + + Ok(contents) + } + + fn decode_hex(value: &str) -> Vec { + let clean = value.trim(); + if clean.is_empty() { Vec::new() } else { hex::decode(clean).expect("valid hex") } + } + + /// Parse a NIST LWC KAT file: blank-line-delimited `Tag = Value` cases. + fn parse_kat(contents: &str) -> Vec> { + let mut cases = Vec::new(); + let mut current = BTreeMap::new(); + + for raw in contents.lines() { + let line = raw.trim(); + if line.is_empty() { + if !current.is_empty() { + cases.push(std::mem::take(&mut current)); + } + continue; + } + if line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + let key = key.trim().to_string(); + let value = value.trim().to_string(); + if key == "Count" && !current.is_empty() { + cases.push(std::mem::take(&mut current)); + } + current.insert(key, value); + } + } + if !current.is_empty() { + cases.push(current); + } + cases + } + + fn field<'a>(case: &'a BTreeMap, names: &[&str]) -> &'a str { + for name in names { + if let Some(v) = case.get(*name) { + return v.as_str(); + } + } + panic!("missing field {names:?}; case had {:?}", case.keys().collect::>()); + } + + fn to_16(bytes: &[u8], what: &str) -> [u8; 16] { + bytes.try_into().unwrap_or_else(|_| panic!("{what} must be 16 bytes, got {}", bytes.len())) + } + + /// Build a `KeyMaterial<16>` for a KAT key. The NIST LWC vectors include an all-zero key + /// (Count=1), which `KeyMaterial::from_bytes_as_type` would otherwise tag + /// `KeyType::Zeroized` / `SecurityStrength::None`; force the type/strength the way a caller + /// who knows the provenance of the key would (see `cli/src/helpers.rs::parse_seed`). + fn key_material(key: &[u8; 16]) -> KeyMaterial<16> { + let mut km = + KeyMaterial::<16>::from_bytes_as_type(key, KeyType::SymmetricCipherKey).unwrap(); + do_hazardous_operations(&mut km, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::_128bit) + }) + .unwrap(); + km + } + + #[test] + fn ascon_aead128_kat() { + let contents = match get_test_data("asconaead128/LWC_AEAD_KAT_128_128.txt") { + Ok(c) => c, + Err(()) => return, + }; + let cases = parse_kat(&contents); + assert!(!cases.is_empty(), "no AEAD cases parsed"); + + for case in &cases { + let key = key_material(&to_16(&decode_hex(field(case, &["Key", "K"])), "key")); + let nonce = to_16(&decode_hex(field(case, &["Nonce", "N"])), "nonce"); + let ad = decode_hex(field(case, &["AD", "A"])); + let pt = decode_hex(field(case, &["PT", "P"])); + let expected_ct = decode_hex(field(case, &["CT", "C"])); + let ad_opt = if ad.is_empty() { None } else { Some(ad.as_slice()) }; + + // One-shot encrypt. + let mut ct = vec![0u8; pt.len() + 16]; + let n = AsconAead128::encrypt(&key, &nonce, ad_opt, &pt, &mut ct).unwrap(); + ct.truncate(n); + assert_eq!(ct, expected_ct, "encrypt mismatch (Count {})", field(case, &["Count"])); + + // One-shot decrypt round-trip. + let mut pt_out = vec![0u8; expected_ct.len()]; + let m = AsconAead128::decrypt(&key, &nonce, ad_opt, &expected_ct, &mut pt_out) + .expect("decrypt should authenticate"); + pt_out.truncate(m); + assert_eq!(pt_out, pt, "decrypt mismatch (Count {})", field(case, &["Count"])); + + // Byte-at-a-time streaming encrypt/decrypt, through the inherent API. + let mut enc = AsconAead128::new(&key, &nonce, ad_opt, true).unwrap(); + let mut stream_ct = pt.clone(); + for byte in stream_ct.iter_mut() { + enc.do_encrypt_update(core::slice::from_mut(byte)); + } + let tag = enc.do_encrypt_final(); + stream_ct.extend_from_slice(&tag); + assert_eq!( + stream_ct, + expected_ct, + "streaming encrypt mismatch (Count {})", + field(case, &["Count"]) + ); + + let mut dec = AsconAead128::new(&key, &nonce, ad_opt, false).unwrap(); + let mut stream_pt = expected_ct[..pt.len()].to_vec(); + for byte in stream_pt.iter_mut() { + dec.do_decrypt_update(core::slice::from_mut(byte)); + } + dec.do_decrypt_final(&tag).expect("streaming decrypt should authenticate"); + assert_eq!( + stream_pt, + pt, + "streaming decrypt mismatch (Count {})", + field(case, &["Count"]) + ); + } + println!("Ascon-AEAD128: {} KAT cases passed", cases.len()); + } + + #[test] + fn ascon_hash256_kat() { + let contents = match get_test_data("asconhash256/LWC_HASH_KAT_256.txt") { + Ok(c) => c, + Err(()) => return, + }; + let cases = parse_kat(&contents); + assert!(!cases.is_empty(), "no Hash256 cases parsed"); + + for case in &cases { + let msg = decode_hex(field(case, &["Msg"])); + let expected = decode_hex(field(case, &["MD"])); + assert_eq!( + AsconHash256::digest(&msg).as_slice(), + expected.as_slice(), + "Hash256 mismatch (Count {})", + field(case, &["Count"]) + ); + } + println!("Ascon-Hash256: {} KAT cases passed", cases.len()); + } + + #[test] + fn ascon_xof128_kat() { + let contents = match get_test_data("asconxof128/LWC_XOF_KAT_128_512.txt") { + Ok(c) => c, + Err(()) => return, + }; + let cases = parse_kat(&contents); + assert!(!cases.is_empty(), "no XOF128 cases parsed"); + + for case in &cases { + let msg = decode_hex(field(case, &["Msg"])); + let expected = decode_hex(field(case, &["MD", "Output"])); + let got = AsconXof128::new().hash_xof(&msg, expected.len()); + assert_eq!(got, expected, "XOF128 mismatch (Count {})", field(case, &["Count"])); + } + println!("Ascon-XOF128: {} KAT cases passed", cases.len()); + } + + #[test] + fn ascon_cxof128_kat() { + let contents = match get_test_data("asconcxof128/LWC_CXOF_KAT_128_512.txt") { + Ok(c) => c, + Err(()) => return, + }; + let cases = parse_kat(&contents); + assert!(!cases.is_empty(), "no CXOF128 cases parsed"); + + for case in &cases { + let msg = decode_hex(field(case, &["Msg"])); + let z = decode_hex(field(case, &["Z", "Customization"])); + let expected = decode_hex(field(case, &["MD", "Output"])); + let got = AsconCXof128::with_customization(&z).unwrap().hash_xof(&msg, expected.len()); + assert_eq!(got, expected, "CXOF128 mismatch (Count {})", field(case, &["Count"])); + } + println!("Ascon-CXOF128: {} KAT cases passed", cases.len()); + } +} diff --git a/crypto/ascon/tests/cxof128_tests.rs b/crypto/ascon/tests/cxof128_tests.rs new file mode 100644 index 00000000..5478ba58 --- /dev/null +++ b/crypto/ascon/tests/cxof128_tests.rs @@ -0,0 +1,221 @@ +//! Ascon-CXOF128 tests (NIST SP 800-232 §5.3). +//! +//! Embedded NIST LWC known-answer vectors (always-on; full sweep in `bc_test_data.rs`) plus +//! domain-separation, streaming/byte-at-a-time equivalence, trait-API, and misuse-guard tests. + +use bouncycastle_ascon::ascon_cxof128::AsconCXof128; +use bouncycastle_ascon::ascon_xof128::AsconXof128; +use bouncycastle_core::errors::HashError; +use bouncycastle_core::traits::XOF; +use bouncycastle_core_test_framework::xof::TestFrameworkXOF; +use bouncycastle_hex as hex; + +/// Embedded NIST LWC Ascon-CXOF128 vectors `(message, customization Z, 512-bit output)` in hex, +/// spanning empty/non-empty customization and message. (Counts 1, 2, 3, 35, 36 of +/// LWC_CXOF_KAT_128_512.txt; each output is 64 bytes.) +const CXOF_KAT: &[(&str, &str, &str)] = &[ + ( + "", + "", + "4F50159EF70BB3DAD8807E034EAEBD44C4FA2CBBC8CF1F05511AB66CDCC529905CA12083FC186AD899B270B1473DC5F7EC88D1052082DCDFE69FB75D269E7B74", + ), + ( + "", + "10", + "0C93A483E7D574D49FE52CCE03EE646117977D57A8AA57704AB4DAF44B501430FF6AC11A5D1FD6F2154B5C65728268270C8BB578508487B8965718ADA6272FD6", + ), + ( + "", + "1011", + "D1106C7622E79FE955BD9D79E03B918E770FE0E0CDDDE28BEB924B02C5FC936B33ACCA299C89ECA5D71886CBBFA4D54A21C55FDE2B679F5E2488063A1719DC32", + ), + ( + "00", + "10", + "63FA8BA86382F2D544580F51322D080424B42C556EB74503CD73CF052BB993BD6F5210984C71C9C445F43CCC5B158226E509BD339CD634414377F79411AA8D5C", + ), + ( + "00", + "1011", + "DF7909DD1F371E54ABBABB50DDEE195720D7EF1BB2CF2271C36A76C19908178BA3255E5A3D31D994C1D217A67AE4D13681AC1ABC4FAA2ECDD1681520BC7D7347", + ), +]; + +fn dh(s: &str) -> Vec { + let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } +} + +fn pattern(len: usize) -> Vec { + (0..len).map(|i| (i as u8).wrapping_mul(7).wrapping_add(1)).collect() +} + +#[test] +fn cxof128_embedded_kat() { + for (msg_hex, z_hex, md_hex) in CXOF_KAT { + let msg = dh(msg_hex); + let z = dh(z_hex); + let expected = dh(md_hex); + let got = AsconCXof128::with_customization(&z).unwrap().hash_xof(&msg, expected.len()); + assert_eq!(got, expected, "msg={msg_hex} z={z_hex}"); + + // `AsconCXof128::default()` uses an empty customization string, so the generic XOF + // framework (which constructs via `Default`) only applies to the empty-Z vectors; the + // non-empty-Z vectors are covered by `cxof128_prefix_property_and_streaming` below. + if z.is_empty() { + // AsconCXof128 has no absorb_last_partial_byte / squeeze_partial_byte_final support, so + // that part of the framework is disabled; everything else (hash_xof, streaming, prefix + // property, chunked absorb, absorb-after-squeeze) is exercised here. + TestFrameworkXOF { enable_partial_byte_tests: false } + .test_xof::(&msg, &expected); + } + } +} + +#[test] +fn cxof128_domain_separation() { + let msg = pattern(48); + + let out_z1 = AsconCXof128::with_customization(b"context-1").unwrap().hash_xof(&msg, 64); + let out_z2 = AsconCXof128::with_customization(b"context-2").unwrap().hash_xof(&msg, 64); + assert_ne!(out_z1, out_z2, "different customization strings must give different output"); + + // Empty-customization CXOF128 must differ from XOF128 (different IV). + let cxof_empty = AsconCXof128::new().hash_xof(&msg, 64); + let xof = AsconXof128::new().hash_xof(&msg, 64); + assert_ne!(cxof_empty, xof, "CXOF128 (empty Z) must differ from XOF128"); +} + +#[test] +fn cxof128_prefix_property_and_streaming() { + let z = b"cust"; + let msg = pattern(70); + let full = AsconCXof128::with_customization(z).unwrap().hash_xof(&msg, 100); + + // Squeezing in several calls yields the same stream (prefix property). + let mut x = AsconCXof128::with_customization(z).unwrap(); + x.absorb(&msg).unwrap(); + let mut piecewise = Vec::new(); + for n in [30usize, 40, 30] { + let mut part = vec![0u8; n]; + x.squeeze_out(&mut part); + piecewise.extend_from_slice(&part); + } + assert_eq!(piecewise, full, "incremental squeeze must equal a single squeeze"); + + // Absorbing in chunks equals one-shot absorb. + for chunk in [1usize, 8, 9, 64] { + let mut xc = AsconCXof128::with_customization(z).unwrap(); + for piece in msg.chunks(chunk) { + xc.absorb(piece).unwrap(); + } + let mut got = vec![0u8; 100]; + xc.squeeze_out(&mut got); + assert_eq!(got, full, "chunked absorb mismatch (chunk={chunk})"); + } +} + +#[test] +fn cxof128_byte_at_a_time_matches_one_shot() { + let msg = pattern(40); // > 8 bytes so byte-at-a-time absorb triggers full-block absorption + let cref = AsconCXof128::with_customization(b"zz").unwrap().hash_xof(&msg, 48); + let mut c = AsconCXof128::with_customization(b"zz").unwrap(); + for &b in &msg { + c.absorb(&[b]).unwrap(); + } + let mut o = [0u8; 48]; + c.squeeze_out(&mut o); + assert_eq!(o.to_vec(), cref, "CXOF128 byte-at-a-time absorb mismatch"); +} + +#[test] +fn cxof128_unsupported_partial_ops_return_err() { + let mut c = AsconCXof128::new(); + assert!(c.absorb_last_partial_byte(0, 3).is_err()); + assert!(AsconCXof128::new().squeeze_partial_byte_final(3).is_err()); + let mut b = 0u8; + assert!(AsconCXof128::new().squeeze_partial_byte_final_out(3, &mut b).is_err()); +} + +#[test] +fn cxof128_absorb_after_squeeze_errors() { + let mut x = AsconCXof128::with_customization(b"z").unwrap(); + x.absorb(b"data").unwrap(); + let mut out = [0u8; 8]; + x.squeeze_out(&mut out); + // Absorbing after squeezing has begun is reported as an error rather than a panic. + assert!(matches!(x.absorb(b"more"), Err(HashError::InvalidState(_)))); +} + +#[test] +fn cxof128_suspendable_state() { + use bouncycastle_core::errors::SuspendableError; + use bouncycastle_core::traits::Suspendable; + use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableState; + + let z = b"customization"; + let data: Vec = (0..30u8).collect(); + + // Reference: uninterrupted absorb + squeeze under the same customization string. + let mut r = AsconCXof128::with_customization(z).unwrap(); + r.absorb(&data).unwrap(); + let mut expected = [0u8; 40]; + r.squeeze_out(&mut expected); + + // Suspend mid-absorb, resume, finish, and confirm the squeezed output matches. (The + // customization string was already absorbed at construction and is not part of the state.) + let mut x = AsconCXof128::with_customization(z).unwrap(); + x.absorb(&data[..5]).unwrap(); + TestFrameworkSuspendableState::new().test(&x); + + let serialized = x.clone().suspend(); + let mut resumed = AsconCXof128::from_suspended(serialized).unwrap(); + resumed.absorb(&data[5..]).unwrap(); + let mut out = [0u8; 40]; + resumed.squeeze_out(&mut out); + assert_eq!(out, expected, "resumed CXOF output must match uninterrupted output"); + + // A corrupted state tag must be rejected. + let mut busted = serialized; + busted[3] ^= 0xFF; + assert!(matches!(AsconCXof128::from_suspended(busted), Err(SuspendableError::InvalidData))); + + // Cross-type guard: an Ascon-XOF128 state (same serialized length) must be rejected by + // Ascon-CXOF128 via the state tag. + let mut xof = AsconXof128::new(); + xof.absorb(&data).unwrap(); + let xof_state = xof.suspend(); + assert!(matches!(AsconCXof128::from_suspended(xof_state), Err(SuspendableError::InvalidData))); + + // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) is only + // valid once squeezing has begun. + let mut bad = serialized; + let len = bad.len(); + bad[len - 2] = 8; // buf_pos = RATE + bad[len - 1] = 0; // squeezing = false + assert!(matches!(AsconCXof128::from_suspended(bad), Err(SuspendableError::InvalidData))); + + // Suspend mid-squeeze (not just mid-absorb) and confirm resuming continues the same stream. + let mut sq = AsconCXof128::with_customization(z).unwrap(); + sq.absorb(&data).unwrap(); + let mut head = [0u8; 5]; + sq.squeeze_out(&mut head); + let squeezing_state = sq.clone().suspend(); + let mut resumed_sq = AsconCXof128::from_suspended(squeezing_state).unwrap(); + let mut tail = [0u8; 35]; + resumed_sq.squeeze_out(&mut tail); + let mut combined = Vec::new(); + combined.extend_from_slice(&head); + combined.extend_from_slice(&tail); + assert_eq!(combined, expected, "resuming mid-squeeze must continue the same output stream"); +} + +#[test] +fn cxof128_customization_length_bound() { + // SP 800-232 §5.3: the customization string shall be at most 2048 bits (256 bytes). + let ok = vec![0u8; 256]; + assert!(AsconCXof128::with_customization(&ok).is_ok()); + + let too_long = vec![0u8; 257]; + assert!(matches!(AsconCXof128::with_customization(&too_long), Err(HashError::InvalidInput(_)))); +} diff --git a/crypto/ascon/tests/hash256_tests.rs b/crypto/ascon/tests/hash256_tests.rs new file mode 100644 index 00000000..8e6ee545 --- /dev/null +++ b/crypto/ascon/tests/hash256_tests.rs @@ -0,0 +1,152 @@ +//! Ascon-Hash256 tests (NIST SP 800-232 §5.1). +//! +//! Embedded NIST LWC known-answer vectors (always-on; full sweep in `bc_test_data.rs`) plus +//! streaming-equivalence, one-shot/trait-API, metadata, and unsupported-partial-op tests. + +use bouncycastle_ascon::ascon_hash256::AsconHash256; +use bouncycastle_core::traits::{Hash, HashAlgParams}; +use bouncycastle_core_test_framework::hash::TestFrameworkHash; +use bouncycastle_hex as hex; + +/// Embedded NIST LWC Ascon-Hash256 vectors `(message, digest)` in hex, spanning empty, sub-block, +/// exact-block, and multi-block messages. (Counts 1, 2, 9, 17, 33 of LWC_HASH_KAT_256.txt.) +const HASH_KAT: &[(&str, &str)] = &[ + ("", "0B3BE5850F2F6B98CAF29F8FDEA89B64A1FA70AA249B8F839BD53BAA304D92B2"), + ("00", "0728621035AF3ED2BCA03BF6FDE900F9456F5330E4B5EE23E7F6A1E70291BC80"), + ("0001020304050607", "B88E497AE8E6FB641B87EF622EB8F2FCA0ED95383F7FFEBE167ACF1099BA764F"), + ( + "000102030405060708090A0B0C0D0E0F", + "3158C1940A2FBADBD68AB661777859B94A689E4EFC375911467ADDD641835C38", + ), + ( + "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "BD9D3D60A66B53868EAB2A5C74539A518A1F60F01EB176C60E43DEE81680B33E", + ), +]; + +fn dh(s: &str) -> Vec { + let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } +} + +fn pattern(len: usize) -> Vec { + (0..len).map(|i| (i as u8).wrapping_mul(7).wrapping_add(1)).collect() +} + +#[test] +fn hash256_embedded_kat() { + for (msg_hex, md_hex) in HASH_KAT { + let msg = dh(msg_hex); + let expected = dh(md_hex); + assert_eq!(AsconHash256::digest(&msg).as_slice(), expected.as_slice(), "msg={msg_hex}"); + + // AsconHash256 has no do_final_partial_bits support, so that part of the framework + // is disabled; everything else (hash/hash_out/do_update+do_final(_out), truncation, + // oversized-buffer zero-fill) is exercised here. + TestFrameworkHash { enable_partial_byte_tests: false } + .test_hash::(&msg, &expected); + } +} + +#[test] +fn hash256_streaming_matches_one_shot() { + let msg = pattern(100); + let expected = AsconHash256::digest(&msg); + + // One-shot APIs agree. + assert_eq!(AsconHash256::new().hash(&msg), expected.to_vec()); + let mut buf = [0u8; 32]; + let mut h = AsconHash256::new(); + h.do_update(&msg); + h.do_final_out(&mut buf); + assert_eq!(buf, expected); + + // Chunked do_update agrees for a range of chunk sizes. + for chunk in [1usize, 7, 8, 9, 16, 33] { + let mut hasher = AsconHash256::new(); + for piece in msg.chunks(chunk) { + hasher.do_update(piece); + } + let mut got = [0u8; 32]; + hasher.do_final_out(&mut got); + assert_eq!(got, expected, "chunked hash mismatch (chunk={chunk})"); + } + + // Byte-at-a-time do_update() agrees. + let mut hasher = AsconHash256::new(); + for &b in &msg { + hasher.do_update(&[b]); + } + let mut got = [0u8; 32]; + hasher.do_final_out(&mut got); + assert_eq!(got, expected, "byte-at-a-time hash mismatch"); +} + +#[test] +fn hash256_metadata_accessors() { + assert_eq!(AsconHash256::OUTPUT_LEN, 32); + let h = AsconHash256::new(); + assert_eq!(h.output_len(), 32); + assert_eq!(h.block_bitlen(), 64); +} + +#[test] +fn hash256_do_final_out_truncates_to_buffer() { + let msg = pattern(50); + let expected = AsconHash256::digest(&msg); + + let mut h = AsconHash256::new(); + h.do_update(&msg); + let mut o = [0u8; 16]; + assert_eq!(h.do_final_out(&mut o), 16); + assert_eq!(o, expected[..16]); +} + +#[test] +fn hash256_hash_out_zeroizes_past_output_len() { + let msg = pattern(50); + let expected = AsconHash256::digest(&msg); + + let mut o = [0xEEu8; 64]; + assert_eq!(AsconHash256::new().hash_out(&msg, &mut o), 32); + assert_eq!(&o[..32], &expected[..]); + assert_eq!(&o[32..], &[0u8; 32]); +} + +#[test] +fn hash256_unsupported_partial_ops_return_err() { + assert!(AsconHash256::new().do_final_partial_bits(0, 3).is_err()); + let mut o = [0u8; 32]; + assert!(AsconHash256::new().do_final_partial_bits_out(0, 3, &mut o).is_err()); +} + +#[test] +fn hash256_suspendable_state() { + use bouncycastle_core::errors::SuspendableError; + use bouncycastle_core::traits::Suspendable; + use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableState; + + let data: Vec = (0..37u8).collect(); + let expected = AsconHash256::digest(&data).to_vec(); + + // Suspend mid-absorb, resume, finish, and confirm the digest matches an uninterrupted run. + let mut h = AsconHash256::new(); + h.do_update(&data[..7]); + TestFrameworkSuspendableState::new().test(&h); + + let serialized = h.clone().suspend(); + let mut resumed = AsconHash256::from_suspended(serialized).unwrap(); + resumed.do_update(&data[7..]); + assert_eq!(resumed.do_final(), expected, "resumed digest must match uninterrupted digest"); + + // A corrupted state tag must be rejected (the tag is the byte after the 3-byte version prefix). + let mut busted = serialized; + busted[3] ^= 0xFF; + assert!(matches!(AsconHash256::from_suspended(busted), Err(SuspendableError::InvalidData))); + + // An out-of-range buffer position must be rejected (buf_pos is the final byte). + let mut bad_pos = serialized; + let last = bad_pos.len() - 1; + bad_pos[last] = 99; // >= RATE (8) + assert!(matches!(AsconHash256::from_suspended(bad_pos), Err(SuspendableError::InvalidData))); +} diff --git a/crypto/ascon/tests/xof128_tests.rs b/crypto/ascon/tests/xof128_tests.rs new file mode 100644 index 00000000..22ed9c0a --- /dev/null +++ b/crypto/ascon/tests/xof128_tests.rs @@ -0,0 +1,183 @@ +//! Ascon-XOF128 tests (NIST SP 800-232 §5.2). +//! +//! Embedded NIST LWC known-answer vectors (always-on; full sweep in `bc_test_data.rs`) plus the +//! prefix property, streaming/byte-at-a-time equivalence, trait-API, and misuse-guard tests. + +use bouncycastle_ascon::ascon_xof128::AsconXof128; +use bouncycastle_core::errors::HashError; +use bouncycastle_core::traits::XOF; +use bouncycastle_core_test_framework::xof::TestFrameworkXOF; +use bouncycastle_hex as hex; + +/// Embedded NIST LWC Ascon-XOF128 vectors `(message, 512-bit output)` in hex, spanning empty, +/// sub-block, exact-block, and multi-block messages. (Counts 1, 2, 9, 17, 33 of +/// LWC_XOF_KAT_128_512.txt; each output is 64 bytes.) +const XOF_KAT: &[(&str, &str)] = &[ + ( + "", + "473D5E6164F58B39DFD84AACDB8AE42EC2D91FED33388EE0D960D9B3993295C6AD77855A5D3B13FE6AD9E6098988373AF7D0956D05A8F1665D2C67D1A3AD10FF", + ), + ( + "00", + "51430E0438ECDF642B393630D977625F5F337656BA58AB1E960784AC32A16E0D446405551F5469384F8EA283CF12E64FA72C426BFEBAEA3AA1529E2C4AB23A2F", + ), + ( + "0001020304050607", + "8D1886F5D3EC4AF8D15B44BC62B74DA6EA91BC28FB82F9C34079B5ED6E38B6C951803D7DFB3C5E512A0EF5E4060062A6FD067F9C73EF9BEE527411BDA67FC896", + ), + ( + "000102030405060708090A0B0C0D0E0F", + "10BFEDC5F6442D3E1D8C324878CE1DDF73B01CAFC365589283AC4CBB98E48DE3CEDA8A41BB0983D539E4D90F6458C5C781724FAD641ED3CDB4779931097440B3", + ), + ( + "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", + "2E5F3403F4171471CC7934B51982CECE8D6628435DB70E89880F3BE4E0B7B05232DFE63C44A836D771337C9C5A2688D1B71ECABE0D5C2006FEF36EF3186138AD", + ), +]; + +fn dh(s: &str) -> Vec { + let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } +} + +fn pattern(len: usize) -> Vec { + (0..len).map(|i| (i as u8).wrapping_mul(7).wrapping_add(1)).collect() +} + +#[test] +fn xof128_embedded_kat() { + for (msg_hex, md_hex) in XOF_KAT { + let msg = dh(msg_hex); + let expected = dh(md_hex); + let got = AsconXof128::new().hash_xof(&msg, expected.len()); + assert_eq!(got, expected, "msg={msg_hex}"); + // AsconXof128 has no absorb_last_partial_byte / squeeze_partial_byte_final support, so that + // part of the framework is disabled; everything else (hash_xof, streaming, prefix property, + // chunked absorb, absorb-after-squeeze) is exercised here. + TestFrameworkXOF { enable_partial_byte_tests: false } + .test_xof::(&msg, &expected); + } +} + +#[test] +fn xof128_prefix_property_and_streaming() { + let msg = pattern(70); + let full = AsconXof128::new().hash_xof(&msg, 100); + + // Squeezing in several calls yields the same stream (prefix property). + let mut x = AsconXof128::new(); + x.absorb(&msg).unwrap(); + let mut piecewise = Vec::new(); + for n in [30usize, 40, 30] { + let mut part = vec![0u8; n]; + x.squeeze_out(&mut part); + piecewise.extend_from_slice(&part); + } + assert_eq!(piecewise, full, "incremental squeeze must equal a single squeeze"); + + // Absorbing in chunks equals one-shot absorb. + for chunk in [1usize, 8, 9, 64] { + let mut xc = AsconXof128::new(); + for piece in msg.chunks(chunk) { + xc.absorb(piece).unwrap(); + } + let mut got = vec![0u8; 100]; + xc.squeeze_out(&mut got); + assert_eq!(got, full, "chunked absorb mismatch (chunk={chunk})"); + } +} + +#[test] +fn xof128_byte_at_a_time_matches_one_shot() { + let msg = pattern(40); // > 8 bytes so byte-at-a-time absorb triggers full-block absorption + let xref = AsconXof128::new().hash_xof(&msg, 48); + let mut x = AsconXof128::new(); + for &b in &msg { + x.absorb(&[b]).unwrap(); + } + let mut o = [0u8; 48]; + x.squeeze_out(&mut o); + assert_eq!(o.to_vec(), xref, "XOF128 byte-at-a-time absorb mismatch"); +} + +#[test] +fn xof128_unsupported_partial_ops_return_err() { + let mut x = AsconXof128::new(); + assert!(x.absorb_last_partial_byte(0, 3).is_err()); + assert!(AsconXof128::new().squeeze_partial_byte_final(3).is_err()); + let mut b = 0u8; + assert!(AsconXof128::new().squeeze_partial_byte_final_out(3, &mut b).is_err()); +} + +#[test] +fn xof128_absorb_after_squeeze_errors() { + let mut x = AsconXof128::new(); + x.absorb(b"data").unwrap(); + let mut out = [0u8; 8]; + x.squeeze_out(&mut out); + // Absorbing after squeezing has begun is a usage error; the trait API reports it as an error + // rather than panicking. + assert!(matches!(x.absorb(b"more"), Err(HashError::InvalidState(_)))); +} + +#[test] +fn xof128_suspendable_state() { + use bouncycastle_ascon::ascon_cxof128::AsconCXof128; + use bouncycastle_core::errors::SuspendableError; + use bouncycastle_core::traits::Suspendable; + use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableState; + + let data: Vec = (0..30u8).collect(); + + // Reference: uninterrupted absorb + squeeze. + let mut r = AsconXof128::new(); + r.absorb(&data).unwrap(); + let mut expected = [0u8; 40]; + r.squeeze_out(&mut expected); + + // Suspend mid-absorb, resume, finish, and confirm the squeezed output matches. + let mut x = AsconXof128::new(); + x.absorb(&data[..5]).unwrap(); + TestFrameworkSuspendableState::new().test(&x); + + let serialized = x.clone().suspend(); + let mut resumed = AsconXof128::from_suspended(serialized).unwrap(); + resumed.absorb(&data[5..]).unwrap(); + let mut out = [0u8; 40]; + resumed.squeeze_out(&mut out); + assert_eq!(out, expected, "resumed XOF output must match uninterrupted output"); + + // A corrupted state tag must be rejected. + let mut busted = serialized; + busted[3] ^= 0xFF; + assert!(matches!(AsconXof128::from_suspended(busted), Err(SuspendableError::InvalidData))); + + // Cross-type guard: an Ascon-CXOF128 state (same serialized length) must be rejected by + // Ascon-XOF128 via the state tag. + let mut c = AsconCXof128::with_customization(b"z").unwrap(); + c.absorb(&data).unwrap(); + let c_state = c.suspend(); + assert!(matches!(AsconXof128::from_suspended(c_state), Err(SuspendableError::InvalidData))); + + // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) is only + // valid once squeezing has begun. + let mut bad = serialized; + let len = bad.len(); + bad[len - 2] = 8; // buf_pos = RATE + bad[len - 1] = 0; // squeezing = false + assert!(matches!(AsconXof128::from_suspended(bad), Err(SuspendableError::InvalidData))); + + // Suspend mid-squeeze (not just mid-absorb) and confirm resuming continues the same stream. + let mut sq = AsconXof128::new(); + sq.absorb(&data).unwrap(); + let mut head = [0u8; 5]; + sq.squeeze_out(&mut head); + let squeezing_state = sq.clone().suspend(); + let mut resumed_sq = AsconXof128::from_suspended(squeezing_state).unwrap(); + let mut tail = [0u8; 35]; + resumed_sq.squeeze_out(&mut tail); + let mut combined = Vec::new(); + combined.extend_from_slice(&head); + combined.extend_from_slice(&tail); + assert_eq!(combined, expected, "resuming mid-squeeze must continue the same output stream"); +} diff --git a/crypto/factory/Cargo.toml b/crypto/factory/Cargo.toml index 22836c5f..c9765796 100644 --- a/crypto/factory/Cargo.toml +++ b/crypto/factory/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +bouncycastle-ascon.workspace = true bouncycastle-core.workspace = true bouncycastle-sha2.workspace = true bouncycastle-sha3.workspace = true diff --git a/crypto/factory/src/hash_factory.rs b/crypto/factory/src/hash_factory.rs index 3e6646ee..a300d14d 100644 --- a/crypto/factory/src/hash_factory.rs +++ b/crypto/factory/src/hash_factory.rs @@ -28,6 +28,8 @@ use crate::{AlgorithmFactory, FactoryError}; use crate::{DEFAULT, DEFAULT_128_BIT, DEFAULT_256_BIT}; +use bouncycastle_ascon as ascon; +use bouncycastle_ascon::ASCON_HASH256_NAME; use bouncycastle_core::errors::HashError; use bouncycastle_core::traits::{Algorithm, Hash, SecurityStrength}; use bouncycastle_sha2 as sha2; @@ -66,6 +68,8 @@ pub enum HashFactory { SHA3_512(sha3::SHA3_512), /// SM3(sm3::SM3), + /// + AsconHash256(ascon::ascon_hash256::AsconHash256), } impl Default for HashFactory { @@ -98,6 +102,7 @@ impl AlgorithmFactory for HashFactory { SHA3_384_NAME => Ok(Self::SHA3_384(sha3::SHA3_384::new())), SHA3_512_NAME => Ok(Self::SHA3_512(sha3::SHA3_512::new())), SM3_NAME => Ok(Self::SM3(sm3::SM3::new())), + ASCON_HASH256_NAME => Ok(Self::AsconHash256(ascon::ascon_hash256::AsconHash256::new())), _ => Err(FactoryError::UnsupportedAlgorithm(format!( "The algorithm: \"{}\" is not a known Hash", alg_name @@ -129,6 +134,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.block_bitlen(), Self::SHA3_512(h) => h.block_bitlen(), Self::SM3(h) => h.block_bitlen(), + Self::AsconHash256(h) => h.block_bitlen(), } } @@ -145,6 +151,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.output_len(), Self::SHA3_512(h) => h.output_len(), Self::SM3(h) => h.output_len(), + Self::AsconHash256(h) => h.output_len(), } } @@ -161,6 +168,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.hash(data), Self::SHA3_512(h) => h.hash(data), Self::SM3(h) => h.hash(data), + Self::AsconHash256(h) => h.hash(data), } } @@ -179,6 +187,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.hash_out(data, output), Self::SHA3_512(h) => h.hash_out(data, output), Self::SM3(h) => h.hash_out(data, output), + Self::AsconHash256(h) => h.hash_out(data, output), } } @@ -195,6 +204,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.do_update(data), Self::SHA3_512(h) => h.do_update(data), Self::SM3(h) => h.do_update(data), + Self::AsconHash256(h) => h.do_update(data), } } @@ -211,6 +221,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.do_final(), Self::SHA3_512(h) => h.do_final(), Self::SM3(h) => h.do_final(), + Self::AsconHash256(h) => h.do_final(), } } @@ -229,6 +240,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.do_final_out(output), Self::SHA3_512(h) => h.do_final_out(output), Self::SM3(h) => h.do_final_out(output), + Self::AsconHash256(h) => h.do_final_out(output), } } @@ -249,6 +261,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.do_final_partial_bits(partial_byte, num_partial_bits), Self::SHA3_512(h) => h.do_final_partial_bits(partial_byte, num_partial_bits), Self::SM3(h) => h.do_final_partial_bits(partial_byte, num_partial_bits), + Self::AsconHash256(h) => h.do_final_partial_bits(partial_byte, num_partial_bits), } } @@ -282,6 +295,9 @@ impl Hash for HashFactory { h.do_final_partial_bits_out(partial_byte, num_partial_bits, output) } Self::SM3(h) => h.do_final_partial_bits_out(partial_byte, num_partial_bits, output), + Self::AsconHash256(h) => { + h.do_final_partial_bits_out(partial_byte, num_partial_bits, output) + } } } @@ -298,6 +314,7 @@ impl Hash for HashFactory { Self::SHA3_384(h) => h.max_security_strength(), Self::SHA3_512(h) => h.max_security_strength(), Self::SM3(h) => h.max_security_strength(), + Self::AsconHash256(h) => h.max_security_strength(), } } } diff --git a/crypto/factory/src/xof_factory.rs b/crypto/factory/src/xof_factory.rs index 27cc5a5e..027a64b2 100644 --- a/crypto/factory/src/xof_factory.rs +++ b/crypto/factory/src/xof_factory.rs @@ -36,12 +36,15 @@ //! ``` use crate::{AlgorithmFactory, FactoryError}; +use bouncycastle_ascon::ASCON_XOF128_NAME; +use bouncycastle_ascon::ascon_xof128::AsconXof128; use bouncycastle_core::errors::HashError; use bouncycastle_core::traits::{Algorithm, Hash, SecurityStrength, XOF, XOFSqueezer}; use bouncycastle_sha3 as sha3; use bouncycastle_sha3::{SHAKE128_NAME, SHAKE256_NAME}; /*** Defaults ***/ + /// pub const DEFAULT_XOF_NAME: &str = SHAKE128_NAME; /// @@ -57,6 +60,8 @@ pub enum XOFFactory { SHAKE128(sha3::SHAKE128), /// SHAKE256(sha3::SHAKE256), + /// + AsconXof128(AsconXof128), } impl Default for XOFFactory { @@ -78,6 +83,7 @@ impl AlgorithmFactory for XOFFactory { match alg_name { SHAKE128_NAME => Ok(Self::SHAKE128(sha3::SHAKE128::new())), SHAKE256_NAME => Ok(Self::SHAKE256(sha3::SHAKE256::new())), + ASCON_XOF128_NAME => Ok(Self::AsconXof128(AsconXof128::new())), _ => Err(FactoryError::UnsupportedAlgorithm(format!( "The algorithm: \"{}\" is not a known XOF", alg_name @@ -85,6 +91,7 @@ impl AlgorithmFactory for XOFFactory { } } } + /// `Hash` requires it, and the factory does not know which algorithm it holds until it is /// constructed, so the constants are placeholders -- the same stance `HashFactory` takes. The /// per-value answers come from [`Hash::output_len`] and [`Hash::max_security_strength`], which @@ -101,8 +108,12 @@ impl Algorithm for XOFFactory { pub enum XOFFactorySqueezer { /// SHAKE128 output. SHAKE128(::Squeezer), + /// SHAKE256 output. SHAKE256(::Squeezer), + + /// Ascon-XOF128 output. + AsconXof128(::Squeezer), } impl XOFSqueezer for XOFFactorySqueezer { @@ -110,6 +121,7 @@ impl XOFSqueezer for XOFFactorySqueezer { match self { Self::SHAKE128(o) => o.do_output(num_bytes), Self::SHAKE256(o) => o.do_output(num_bytes), + Self::AsconXof128(o) => o.do_output(num_bytes), } } @@ -117,6 +129,7 @@ impl XOFSqueezer for XOFFactorySqueezer { match self { Self::SHAKE128(o) => o.do_output_out(output), Self::SHAKE256(o) => o.do_output_out(output), + Self::AsconXof128(o) => o.do_output_out(output), } } } @@ -126,6 +139,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.block_bitlen(), Self::SHAKE256(h) => h.block_bitlen(), + Self::AsconXof128(h) => h.block_bitlen(), } } @@ -133,6 +147,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.output_len(), Self::SHAKE256(h) => h.output_len(), + Self::AsconXof128(h) => h.output_len(), } } @@ -140,6 +155,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.hash(data), Self::SHAKE256(h) => h.hash(data), + Self::AsconXof128(h) => h.hash(data), } } @@ -147,6 +163,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.hash_out(data, output), Self::SHAKE256(h) => h.hash_out(data, output), + Self::AsconXof128(h) => h.hash_out(data, output), } } @@ -154,6 +171,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_update(data), Self::SHAKE256(h) => h.do_update(data), + Self::AsconXof128(h) => h.do_update(data), } } @@ -161,6 +179,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_final(), Self::SHAKE256(h) => h.do_final(), + Self::AsconXof128(h) => h.do_final(), } } @@ -168,6 +187,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_final_out(output), Self::SHAKE256(h) => h.do_final_out(output), + Self::AsconXof128(h) => h.do_final_out(output), } } @@ -179,6 +199,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_final_partial_bits(partial_byte, num_bits), Self::SHAKE256(h) => h.do_final_partial_bits(partial_byte, num_bits), + Self::AsconXof128(h) => h.do_final_partial_bits(partial_byte, num_bits), } } @@ -191,6 +212,9 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_final_partial_bits_out(partial_byte, num_bits, output), Self::SHAKE256(h) => h.do_final_partial_bits_out(partial_byte, num_bits, output), + Self::AsconXof128(h) => { + h.do_final_partial_bits_out(partial_byte, num_bits, output) + } } } @@ -198,6 +222,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => Hash::max_security_strength(h), Self::SHAKE256(h) => Hash::max_security_strength(h), + Self::AsconXof128(h) => Hash::max_security_strength(h), } } } @@ -209,6 +234,7 @@ impl XOF for XOFFactory { match self { Self::SHAKE128(h) => XOFFactorySqueezer::SHAKE128(h.into_squeezer()), Self::SHAKE256(h) => XOFFactorySqueezer::SHAKE256(h.into_squeezer()), + Self::AsconXof128(h) => XOFFactorySqueezer::AsconXof128(h.into_squeezer()), } } @@ -218,12 +244,15 @@ impl XOF for XOFFactory { num_bits: usize, ) -> Result { Ok(match self { - Self::SHAKE128(h) => { - XOFFactorySqueezer::SHAKE128(h.into_squeezer_partial_bits(partial_byte, num_bits)?) - } - Self::SHAKE256(h) => { - XOFFactorySqueezer::SHAKE256(h.into_squeezer_partial_bits(partial_byte, num_bits)?) - } + Self::SHAKE128(h) => XOFFactorySqueezer::SHAKE128( + h.into_squeezer_partial_bits(partial_byte, num_bits)?, + ), + Self::SHAKE256(h) => XOFFactorySqueezer::SHAKE256( + h.into_squeezer_partial_bits(partial_byte, num_bits)?, + ), + Self::AsconXof128(h) => XOFFactorySqueezer::AsconXof128( + h.into_squeezer_partial_bits(partial_byte, num_bits)?, + ), }) } @@ -231,6 +260,7 @@ impl XOF for XOFFactory { match self { Self::SHAKE128(h) => h.xof(data, result_len), Self::SHAKE256(h) => h.xof(data, result_len), + Self::AsconXof128(h) => h.xof(data, result_len), } } @@ -240,6 +270,7 @@ impl XOF for XOFFactory { match self { Self::SHAKE128(h) => h.xof_out(data, output), Self::SHAKE256(h) => h.xof_out(data, output), + Self::AsconXof128(h) => h.xof_out(data, output), } } -} +} \ No newline at end of file diff --git a/crypto/factory/tests/hash_factory_tests.rs b/crypto/factory/tests/hash_factory_tests.rs index 22f5a3b4..5d70757f 100644 --- a/crypto/factory/tests/hash_factory_tests.rs +++ b/crypto/factory/tests/hash_factory_tests.rs @@ -164,6 +164,30 @@ mod hash_factory_tests { assert_eq!(XOFFactory::new("SHAKE256").unwrap().xof(&DUMMY_SEED[..512], 32), b"\xa1\xd7\x18\x85\xb0\xa8\x41\xf0\x3d\x1d\xc7\xf2\x73\x8a\x15\xcc\x98\x40\x71\xa1\x7f\xfe\xd5\xec\xac\xb9\xf5\x87\x20\xa4\x73\xbe"); } + #[test] + fn ascon_hash_tests() { + use bouncycastle_ascon::ASCON_HASH256_NAME; + use bouncycastle_ascon::ascon_hash256::AsconHash256; + use bouncycastle_factory::FactoryError; + + let direct = AsconHash256::new().hash(&DUMMY_SEED[..512]); + + // Construct by literal name and by the crate's name constant; both must match the + // direct implementation. + let by_name = HashFactory::new("Ascon-Hash256").unwrap(); + assert_eq!(by_name.output_len(), 32); + assert_eq!(by_name.hash(&DUMMY_SEED[..512]), direct); + + let by_const = HashFactory::new(ASCON_HASH256_NAME).unwrap(); + assert_eq!(by_const.hash(&DUMMY_SEED[..512]), direct); + + // Unknown algorithm names are still rejected. + assert!(matches!( + HashFactory::new("Ascon-Hash999"), + Err(FactoryError::UnsupportedAlgorithm(_)) + )); + } + #[test] fn test_defaults() { // All the ways to get "default" diff --git a/crypto/factory/tests/xof_factory_tests.rs b/crypto/factory/tests/xof_factory_tests.rs index bea0ca87..a3d3d1b7 100644 --- a/crypto/factory/tests/xof_factory_tests.rs +++ b/crypto/factory/tests/xof_factory_tests.rs @@ -1,7 +1,9 @@ -//! `XOFFactory` is a pass-through to the SHAKE types in `bouncycastle-sha3`, so the oracle for +//! `XOFFactory` is a pass-through to the concrete XOF implementations, so the oracle for //! every method is the same call on the underlying type. Each check below runs the factory and the //! direct type side by side on the same input; nothing here is an expected value written by hand. +use bouncycastle_ascon::ASCON_XOF128_NAME; +use bouncycastle_ascon::ascon_xof128::AsconXof128; use bouncycastle_core::errors::HashError; use bouncycastle_core::traits::{Hash, XOF, XOFSqueezer}; use bouncycastle_core_test_framework::xof::TestFrameworkXOF; @@ -51,18 +53,29 @@ fn check_against(make: impl Fn() -> XOFFactory, ctx: &str) { let mut f = make(); f.do_update(MSG); - assert_eq!(f.do_final_partial_bits(0x05, 3).unwrap(), expected_bits, "{ctx}: partial bits"); + assert_eq!( + f.do_final_partial_bits(0x05, 3).unwrap(), + expected_bits, + "{ctx}: partial bits" + ); let mut f = make(); f.do_update(MSG); let mut out = vec![0u8; n]; - assert_eq!(f.do_final_partial_bits_out(0x05, 3, &mut out).unwrap(), n, "{ctx}: ..._out length"); + assert_eq!( + f.do_final_partial_bits_out(0x05, 3, &mut out).unwrap(), + n, + "{ctx}: ..._out length" + ); assert_eq!(out, expected_bits, "{ctx}: do_final_partial_bits_out"); let mut f = make(); f.do_update(MSG); assert!( - matches!(f.do_final_partial_bits(0xFF, 8), Err(HashError::InvalidLength(_))), + matches!( + f.do_final_partial_bits(0xFF, 8), + Err(HashError::InvalidLength(_)) + ), "{ctx}: eight partial bits is not a partial byte" ); @@ -70,47 +83,104 @@ fn check_against(make: impl Fn() -> XOFFactory, ctx: &str) { let mut s = S::default(); s.do_update(MSG); let long = s.into_squeezer().do_output(3 * n); - assert_eq!(&long[..n], &expected[..], "the direct type's hash is a prefix of its stream"); + assert_eq!( + &long[..n], + &expected[..], + "the direct type's hash is a prefix of its stream" + ); let mut f = make(); f.do_update(MSG); let mut fo = f.into_squeezer(); assert_eq!(fo.do_output(n), &long[..n], "{ctx}: do_output"); + let mut buf = vec![0u8; 2 * n]; - assert_eq!(fo.do_output_out(&mut buf), 2 * n, "{ctx}: do_output_out returns the length"); + assert_eq!( + fo.do_output_out(&mut buf), + 2 * n, + "{ctx}: do_output_out returns the length" + ); assert_eq!(buf, &long[n..], "{ctx}: do_output_out continues the stream"); let mut s = S::default(); s.do_update(MSG); - let want = s.into_squeezer_partial_bits(0x05, 3).unwrap().do_output(n); + let want = s + .into_squeezer_partial_bits(0x05, 3) + .unwrap() + .do_output(n); + let mut f = make(); f.do_update(MSG); assert_eq!( - f.into_squeezer_partial_bits(0x05, 3).unwrap().do_output(n), + f.into_squeezer_partial_bits(0x05, 3) + .unwrap() + .do_output(n), want, "{ctx}: into_squeezer_partial_bits" ); + let mut f = make(); f.do_update(MSG); - assert!(matches!(f.into_squeezer_partial_bits(0xFF, 8), Err(HashError::InvalidLength(_)))); + assert!(matches!( + f.into_squeezer_partial_bits(0xFF, 8), + Err(HashError::InvalidLength(_)) + )); // the one-shots assert_eq!(make().xof(MSG, 3 * n), long, "{ctx}: xof"); + let mut out = vec![0xFFu8; 3 * n]; - assert_eq!(make().xof_out(MSG, &mut out), 3 * n, "{ctx}: xof_out returns the length"); + assert_eq!( + make().xof_out(MSG, &mut out), + 3 * n, + "{ctx}: xof_out returns the length" + ); assert_eq!(out, long, "{ctx}: xof_out"); } #[test] fn shake128_by_name_matches_the_direct_type() { - check_against::(|| XOFFactory::new(SHAKE128_NAME).unwrap(), "SHAKE128 by constant"); - check_against::(|| XOFFactory::new("SHAKE128").unwrap(), "SHAKE128 by string"); + check_against::( + || XOFFactory::new(SHAKE128_NAME).unwrap(), + "SHAKE128 by constant", + ); + check_against::( + || XOFFactory::new("SHAKE128").unwrap(), + "SHAKE128 by string", + ); } #[test] fn shake256_by_name_matches_the_direct_type() { - check_against::(|| XOFFactory::new(SHAKE256_NAME).unwrap(), "SHAKE256 by constant"); - check_against::(|| XOFFactory::new("SHAKE256").unwrap(), "SHAKE256 by string"); + check_against::( + || XOFFactory::new(SHAKE256_NAME).unwrap(), + "SHAKE256 by constant", + ); + check_against::( + || XOFFactory::new("SHAKE256").unwrap(), + "SHAKE256 by string", + ); +} + +/// Verify that the Ascon-XOF128 factory registration resolves to the same implementation +/// as constructing Ascon-XOF128 directly. +#[test] +fn ascon_xof128_by_name_matches_the_direct_type() { + let direct = AsconXof128::new().xof(MSG, 64); + + // Construct using the crate constant. + assert_eq!( + XOFFactory::new(ASCON_XOF128_NAME).unwrap().xof(MSG, 64), + direct, + "Ascon-XOF128 by constant" + ); + + // Construct using the literal algorithm name. + assert_eq!( + XOFFactory::new("Ascon-XOF128").unwrap().xof(MSG, 64), + direct, + "Ascon-XOF128 by string" + ); } /// The configured defaults: SHAKE128 for the general and 128-bit defaults, SHAKE256 for 256-bit. @@ -123,9 +193,18 @@ fn defaults() { #[test] fn unknown_names_are_refused() { - for name in ["SHAKE512", "shake128", "", "cSHAKE128"] { + for name in [ + "SHAKE512", + "shake128", + "", + "cSHAKE128", + "Ascon-XOF999", + ] { assert!( - matches!(XOFFactory::new(name), Err(FactoryError::UnsupportedAlgorithm(_))), + matches!( + XOFFactory::new(name), + Err(FactoryError::UnsupportedAlgorithm(_)) + ), "{name:?} must not construct a XOF" ); } @@ -135,14 +214,16 @@ fn unknown_names_are_refused() { #[test] fn test_framework_xof() { let framework = TestFrameworkXOF::new(); + framework.test_xof( || XOFFactory::new(SHAKE128_NAME).unwrap(), MSG, &SHAKE128::new().xof(MSG, 100), ); + framework.test_xof( || XOFFactory::new(SHAKE256_NAME).unwrap(), MSG, &SHAKE256::new().xof(MSG, 100), ); -} +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 16a27ad1..4cd3b075 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub use bouncycastle_aes as aes; +pub use bouncycastle_ascon as ascon; pub use bouncycastle_base64 as base64; pub use bouncycastle_core as core; pub use bouncycastle_factory as factory; From 2c479f49fcf04a453a1c39ec37bb38bbceb2098a Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Thu, 17 Sep 2026 20:35:28 +0700 Subject: [PATCH 03/16] Rebased #120 onto #118. Ported ASCON XOF/CXOF to new Hash/XOF/XOFSqueezer API and updated factory/CLI/tests/benches to compile against the new API (#119) --- cli/src/helpers.rs | 18 +- cli/src/main.rs | 4 + crypto/ascon/benches/ascon_benches.rs | 17 +- crypto/ascon/src/ascon_cxof128.rs | 331 ++++++++++++++++------ crypto/ascon/src/ascon_xof128.rs | 329 +++++++++++++++------ crypto/ascon/tests/bc_test_data.rs | 41 ++- crypto/ascon/tests/cxof128_tests.rs | 218 ++++++++++---- crypto/ascon/tests/xof128_tests.rs | 192 +++++++++---- crypto/factory/src/xof_factory.rs | 18 +- crypto/factory/tests/xof_factory_tests.rs | 84 ++---- 10 files changed, 877 insertions(+), 375 deletions(-) diff --git a/cli/src/helpers.rs b/cli/src/helpers.rs index 2873e1e6..fa476b04 100644 --- a/cli/src/helpers.rs +++ b/cli/src/helpers.rs @@ -1,7 +1,7 @@ use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::traits::{Hash, SecurityStrength, XOF}; +use bouncycastle::core::traits::{Hash, SecurityStrength, XOF, XOFSqueezer}; use bouncycastle::hex; use std::fs::File; use std::io; @@ -58,6 +58,7 @@ pub(crate) fn read_from_file_or_stdin(filename: &Option) -> Vec { pub(crate) fn write_bytes_or_hex(bytes: &[u8], output_hex: bool) { // first flush stdout to ensure any buffered data is written io::stdout().flush().unwrap(); + if output_hex { for b in bytes.iter() { print!("{b:02x}"); @@ -69,6 +70,7 @@ pub(crate) fn write_bytes_or_hex(bytes: &[u8], output_hex: bool) { pub(crate) fn write_bytes_or_hex_to_file(bytes: &[u8], filename: &str, output_hex: bool) { let mut file = File::create(filename).expect("Failed to create file"); + if output_hex { for b in bytes.iter() { file.write_all(format!("{b:02x}").as_bytes()).unwrap(); @@ -89,13 +91,15 @@ pub(crate) fn parse_seed(bytes: &[u8]) -> Result { - // it's not hex, so take the fist SEED_LEN bytes of the raw binary + // it's not hex, so take the first SEED_LEN bytes of the raw binary if bytes.len() < SEED_LEN || bytes.len() > SEED_LEN + 1 { return Err(()); } + bytes[..SEED_LEN].try_into().unwrap() } }; @@ -108,12 +112,14 @@ pub(crate) fn parse_seed(bytes: &[u8]) -> Result { sha3_cmd::cshake_cmd(256, *length, function_name, customization, *x); + } Some(Subcommands::AsconHash256 { x }) => { ascon_cmd::hash256_cmd(*x); } diff --git a/crypto/ascon/benches/ascon_benches.rs b/crypto/ascon/benches/ascon_benches.rs index eebe3f17..2238302c 100644 --- a/crypto/ascon/benches/ascon_benches.rs +++ b/crypto/ascon/benches/ascon_benches.rs @@ -26,12 +26,14 @@ fn bench_aead128_encrypt(c: &mut Criterion) { let mut group = c.benchmark_group("ascon::AsconAead128"); group.throughput(Throughput::Bytes(DATA_LEN as u64)); + group.bench_function(format!("{DATA_LEN} bytes -- ::encrypt()"), |b| { b.iter(|| { AsconAead128::encrypt(&key, &nonce, None, black_box(&data), &mut out).unwrap(); black_box(&out); }) }); + group.finish(); } @@ -41,12 +43,14 @@ fn bench_hash256(c: &mut Criterion) { let mut group = c.benchmark_group("ascon::AsconHash256"); group.throughput(Throughput::Bytes(DATA_LEN as u64)); + group.bench_function(format!("{DATA_LEN} bytes -- ::hash_out()"), |b| { b.iter(|| { AsconHash256::new().hash_out(black_box(&data), &mut digest); black_box(&digest); }) }); + group.finish(); } @@ -56,15 +60,17 @@ fn bench_xof128(c: &mut Criterion) { let mut group = c.benchmark_group("ascon::AsconXof128"); group.throughput(Throughput::Bytes((DATA_LEN + out.len()) as u64)); + group.bench_function( - format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::hash_xof_out()"), + format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::xof_out()"), |b| { b.iter(|| { - AsconXof128::new().hash_xof_out(black_box(&data), &mut out); + AsconXof128::new().xof_out(black_box(&data), &mut out); black_box(&out); }) }, ); + group.finish(); } @@ -75,17 +81,20 @@ fn bench_cxof128(c: &mut Criterion) { let mut group = c.benchmark_group("ascon::AsconCXof128"); group.throughput(Throughput::Bytes((DATA_LEN + out.len()) as u64)); + group.bench_function( - format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::hash_xof_out()"), + format!("input: {DATA_LEN} bytes, output: 64 bytes -- ::xof_out()"), |b| { b.iter(|| { AsconCXof128::with_customization(customization) .unwrap() - .hash_xof_out(black_box(&data), &mut out); + .xof_out(black_box(&data), &mut out); + black_box(&out); }) }, ); + group.finish(); } diff --git a/crypto/ascon/src/ascon_cxof128.rs b/crypto/ascon/src/ascon_cxof128.rs index 4a0b055f..ae6d18db 100644 --- a/crypto/ascon/src/ascon_cxof128.rs +++ b/crypto/ascon/src/ascon_cxof128.rs @@ -3,10 +3,14 @@ //! A variant of Ascon-XOF128 that first absorbs a user-supplied customization string `Z` //! (length-prefixed per SP 800-232 Alg. 7) to provide domain separation. Same sponge parameters as //! Ascon-XOF128 (rate = 64 bits, capacity = 256 bits, `Ascon-p[12]`). +//! +//! Input absorption and output squeezing are represented by separate Rust types: +//! [`AsconCXof128`] accepts input, while [`AsconCXof128Squeezer`] produces the +//! extendable output stream. use bouncycastle_core::errors::{HashError, SuspendableError}; use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; -use bouncycastle_core::traits::{Algorithm, SecurityStrength, Suspendable, XOF}; +use bouncycastle_core::traits::{Algorithm, Hash, SecurityStrength, Suspendable, XOF, XOFSqueezer}; use bouncycastle_utils::secret::Secret; use crate::sponge::{RATE, Sponge}; @@ -14,6 +18,12 @@ use crate::sponge::{RATE, Sponge}; /// Maximum customization-string length in bytes (2048 bits, per SP 800-232 §5.3). const MAX_CUSTOMIZATION_BYTES: usize = 256; +/// Nominal hash-view output length for Ascon-CXOF128. +/// +/// XOFs do not have an inherent output length. The [`Hash`] view therefore uses +/// twice the 128-bit security strength, matching the convention used for SHAKE128. +const NOMINAL_OUTPUT_LEN: usize = 32; + /// Ascon-CXOF128 customized extendable-output function (NIST SP 800-232 §5.3). #[derive(Clone)] pub struct AsconCXof128 { @@ -34,6 +44,7 @@ impl AsconCXof128 { 0x00C8356340A347F0, ]); sponge.reset_buffer(); + Self { sponge } } @@ -47,6 +58,7 @@ impl AsconCXof128 { "Ascon-CXOF128 customization string exceeds 256 bytes", )); } + if z.is_empty() { return Ok(Self::new()); } @@ -68,18 +80,23 @@ impl AsconCXof128 { // Customization is complete; reset the buffer to begin the message-absorb phase. sponge.reset_buffer(); + Ok(Self { sponge }) } - // Squeeze `output.len()` bytes of output. May be called multiple times; the first call ends the - // absorb phase by padding and absorbing the final block. Returns the number of bytes written. + /// Produces `output.len()` bytes from the XOF stream. + /// + /// The first call ends the message-absorb phase by padding and absorbing the + /// final message block. Subsequent calls continue the same output stream. fn squeeze_into(&mut self, output: &mut [u8]) -> usize { - let written = output.len(); + output.fill(0); + if !self.sponge.squeezing() { self.sponge.pad_and_absorb(); } + self.sponge.squeeze(output); - written + output.len() } } @@ -94,57 +111,114 @@ impl Algorithm for AsconCXof128 { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; } -impl XOF for AsconCXof128 { - fn hash_xof(mut self, data: &[u8], result_len: usize) -> Vec { - self.sponge.absorb(data); - let mut out = vec![0u8; result_len]; - self.squeeze_into(&mut out); +/// The output-producing half of [`AsconCXof128`]. +/// +/// Calling [`XOF::into_squeezer`] consumes the absorbing `AsconCXof128`, so once +/// output begins there is no longer an object on which [`Hash::do_update`] can +/// be called. +#[derive(Clone)] +pub struct AsconCXof128Squeezer { + xof: AsconCXof128, +} + +impl XOFSqueezer for AsconCXof128Squeezer { + fn do_output(&mut self, num_bytes: usize) -> Vec { + let mut out = vec![0u8; num_bytes]; + self.do_output_out(&mut out); out } - fn hash_xof_out(mut self, data: &[u8], output: &mut [u8]) -> usize { - self.sponge.absorb(data); - self.squeeze_into(output) + fn do_output_out(&mut self, output: &mut [u8]) -> usize { + self.xof.squeeze_into(output) } +} - fn absorb(&mut self, data: &[u8]) -> Result<(), HashError> { - if self.sponge.squeezing() { - return Err(HashError::InvalidState( - "Ascon-CXOF128 cannot absorb after squeezing has begun", - )); - } - self.sponge.absorb(data); - Ok(()) +impl Hash for AsconCXof128 { + /// Ascon-CXOF128 absorbs at a rate of 64 bits. + fn block_bitlen(&self) -> usize { + RATE * 8 } - fn absorb_last_partial_byte( - &mut self, - _partial_byte: u8, - _num_partial_bits: usize, - ) -> Result<(), HashError> { - Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte input")) + /// Nominal digest size used when Ascon-CXOF128 is viewed through [`Hash`]. + fn output_len(&self) -> usize { + NOMINAL_OUTPUT_LEN } - fn squeeze(&mut self, num_bytes: usize) -> Vec { - let mut out = vec![0u8; num_bytes]; - self.squeeze_into(&mut out); - out + fn hash(mut self, data: &[u8]) -> Vec { + self.do_update(data); + self.do_final() + } + + fn hash_out(mut self, data: &[u8], output: &mut [u8]) -> usize { + self.do_update(data); + self.do_final_out(output) } - fn squeeze_out(&mut self, output: &mut [u8]) -> usize { - self.squeeze_into(output) + fn do_update(&mut self, data: &[u8]) { + // A caller-visible AsconCXof128 is always in the absorbing phase: + // into_squeezer() consumes it before output can begin. + debug_assert!( + !self.sponge.squeezing(), + "a reachable AsconCXof128 must not already be squeezing" + ); + + self.sponge.absorb(data); } - fn squeeze_partial_byte_final(self, _num_bits: usize) -> Result { - Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte output")) + fn do_final(self) -> Vec { + let output_len = self.output_len(); + self.into_squeezer().do_final(output_len) } - fn squeeze_partial_byte_final_out( + fn do_final_out(self, output: &mut [u8]) -> usize { + let output_len = self.output_len(); + let written = output_len.min(output.len()); + + // Hash::do_final_out requires bytes beyond output_len to be zero. + output[written..].fill(0); + + self.into_squeezer().do_final_out(&mut output[..written]) + } + + fn do_final_partial_bits( self, - _num_bits: usize, - _output: &mut u8, - ) -> Result<(), HashError> { - Err(HashError::InvalidInput("Ascon-CXOF128 does not support partial byte output")) + partial_byte: u8, + num_bits: usize, + ) -> Result, HashError> { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-CXOF128 does not support partial byte input", + )); + } + + // A zero-bit partial byte means the message is byte-aligned. + let _ = partial_byte; + Ok(self.do_final()) + } + + fn do_final_partial_bits_out( + self, + partial_byte: u8, + num_bits: usize, + output: &mut [u8], + ) -> Result { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-CXOF128 does not support partial byte input", + )); + } + + // A zero-bit partial byte means the message is byte-aligned. + let _ = partial_byte; + Ok(self.do_final_out(output)) } fn max_security_strength(&self) -> SecurityStrength { @@ -152,67 +226,156 @@ impl XOF for AsconCXof128 { } } -/// Length in bytes of the serialized state of [`AsconCXof128`]. -/// Layout: 3-byte library version || 1-byte state tag || 40-byte sponge state (5 × u64 LE) -/// || 8-byte rate buffer || 1-byte buffer position || 1-byte squeezing flag. +impl XOF for AsconCXof128 { + type Squeezer = AsconCXof128Squeezer; + + fn into_squeezer(self) -> Self::Squeezer { + AsconCXof128Squeezer { xof: self } + } + + fn into_squeezer_partial_bits( + self, + partial_byte: u8, + num_bits: usize, + ) -> Result { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-CXOF128 does not support partial byte input", + )); + } + + // Per the XOF trait contract, zero partial bits is exactly the + // byte-aligned into_squeezer() operation. + let _ = partial_byte; + Ok(self.into_squeezer()) + } +} + +/// Length in bytes of the serialized Ascon-CXOF128 state. /// -/// Note: the customization string is absorbed at construction time and is not part of the -/// suspended state; resuming continues the message-absorb / squeeze phase already in progress. +/// Layout: +/// +/// - 3-byte library version +/// - 1-byte state tag +/// - 40-byte sponge state (`5 × u64`, little endian) +/// - 8-byte rate buffer +/// - 1-byte buffer position +/// - 1-byte squeezing flag +/// +/// The customization string is already absorbed during construction, so it +/// does not need to be stored separately in the suspended representation. pub const SUSPENDED_ASCON_CXOF128_STATE_LEN: usize = 54; -// Distinguishes an Ascon-CXOF128 serialized state from the other (same-shaped) Ascon sponge states. +/// Distinguishes an Ascon-CXOF128 serialized state from other Ascon sponge states. const CXOF128_STATE_TAG: u8 = 0x03; +/// Deserialize the common sponge representation used by both the absorbing +/// [`AsconCXof128`] and squeezing [`AsconCXof128Squeezer`] forms. +fn deserialize_sponge( + serialized_state: [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN], +) -> Result { + // Infallible: check_lib_ver returns exactly 51 bytes after removing + // the three-byte library-version prefix. + let input: &[u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != CXOF128_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + + let mut state = Secret::<[u64; 5]>::new(); + + for i in 0..5 { + // Each selected slice is exactly eight bytes. + state[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + + let mut buf = Secret::<[u8; RATE]>::new(); + buf.copy_from_slice(&input[41..49]); + + let buf_pos = input[49] as usize; + + let squeezing = match input[50] { + 0 => false, + 1 => true, + _ => return Err(SuspendableError::InvalidData), + }; + + // While absorbing, a full rate buffer is drained immediately, so the + // position must be strictly less than RATE. During squeezing, RATE is + // allowed to represent "no buffered squeezed byte remains". + let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; + + if !valid_pos { + return Err(SuspendableError::InvalidData); + } + + Ok(Sponge::from_parts(state, buf, buf_pos, squeezing)) +} + +/// Serialize the common sponge representation. +fn serialize_sponge(sponge: &Sponge) -> [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_CXOF128_STATE_LEN]; + + // Infallible: add_lib_ver returns exactly 51 bytes. + let out: &mut [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = CXOF128_STATE_TAG; + + let state = sponge.state_words(); + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); + } + + out[41..49].copy_from_slice(&sponge.buf_bytes()); + + debug_assert!(sponge.buf_pos() <= RATE); + out[49] = sponge.buf_pos() as u8; + out[50] = sponge.squeezing() as u8; + + out_to_return +} + impl Suspendable for AsconCXof128 { fn suspend(self) -> [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN] { - let mut out_to_return = [0u8; SUSPENDED_ASCON_CXOF128_STATE_LEN]; - // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_CXOF128_STATE_LEN - 3 = 51 bytes. - let out: &mut [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = - add_lib_ver(&mut out_to_return).try_into().unwrap(); - - out[0] = CXOF128_STATE_TAG; - let state = self.sponge.state_words(); - for i in 0..5 { - out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); - } - out[41..49].copy_from_slice(&self.sponge.buf_bytes()); - debug_assert!(self.sponge.buf_pos() <= RATE); - out[49] = self.sponge.buf_pos() as u8; - out[50] = self.sponge.squeezing() as u8; - - out_to_return + serialize_sponge(&self.sponge) } fn from_suspended( serialized_state: [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN], ) -> Result { - // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_CXOF128_STATE_LEN - 3 = 51 bytes. - let input: &[u8; SUSPENDED_ASCON_CXOF128_STATE_LEN - 3] = - check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + let sponge = deserialize_sponge(serialized_state)?; - if input[0] != CXOF128_STATE_TAG { + // The absorbing type must never contain a state that has already + // transitioned into squeezing. Such states belong to the squeezer. + if sponge.squeezing() { return Err(SuspendableError::InvalidData); } - let mut s = Secret::<[u64; 5]>::new(); - for i in 0..5 { - // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. - s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); - } - let mut buf = Secret::<[u8; RATE]>::new(); - buf.copy_from_slice(&input[41..49]); - let buf_pos = input[49] as usize; - let squeezing = match input[50] { - 0 => false, - 1 => true, - _ => return Err(SuspendableError::InvalidData), - }; - // While absorbing, buf_pos must be < RATE (a full buffer is drained immediately); once - // squeezing, buf_pos may equal RATE (meaning "no leftover squeezed byte buffered"). - let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; - if !valid_pos { + + Ok(Self { sponge }) + } +} + +impl Suspendable for AsconCXof128Squeezer { + fn suspend(self) -> [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN] { + serialize_sponge(&self.xof.sponge) + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_CXOF128_STATE_LEN], + ) -> Result { + let sponge = deserialize_sponge(serialized_state)?; + + // The squeezer is only valid after the phase transition has happened. + if !sponge.squeezing() { return Err(SuspendableError::InvalidData); } - Ok(AsconCXof128 { sponge: Sponge::from_parts(s, buf, buf_pos, squeezing) }) + Ok(Self { xof: AsconCXof128 { sponge } }) } } diff --git a/crypto/ascon/src/ascon_xof128.rs b/crypto/ascon/src/ascon_xof128.rs index 0b6e8a8f..2e087df9 100644 --- a/crypto/ascon/src/ascon_xof128.rs +++ b/crypto/ascon/src/ascon_xof128.rs @@ -1,15 +1,23 @@ //! Ascon-XOF128 extendable-output function (NIST SP 800-232 §5.2). //! -//! Sponge mode over `Ascon-p[12]` with rate = 64 bits, capacity = 256 bits. Supports the streaming -//! absorb/squeeze API of SP 800-232 §5.4 (squeeze may be called repeatedly). +//! Sponge mode over `Ascon-p[12]` with rate = 64 bits and capacity = 256 bits. +//! Input absorption and output squeezing are represented by separate Rust types: +//! [`AsconXof128`] accepts input, while [`AsconXof128Squeezer`] produces the +//! extendable output stream. use bouncycastle_core::errors::{HashError, SuspendableError}; use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; -use bouncycastle_core::traits::{Algorithm, SecurityStrength, Suspendable, XOF}; +use bouncycastle_core::traits::{Algorithm, Hash, SecurityStrength, Suspendable, XOF, XOFSqueezer}; use bouncycastle_utils::secret::Secret; use crate::sponge::{RATE, Sponge}; +/// Nominal hash-view output length for Ascon-XOF128. +/// +/// XOFs do not have an inherent output length. The [`Hash`] view therefore uses +/// twice the 128-bit security strength, matching the convention used for SHAKE128. +const NOMINAL_OUTPUT_LEN: usize = 32; + /// Ascon-XOF128 as specified in NIST SP 800-232. #[derive(Clone)] pub struct AsconXof128 { @@ -19,7 +27,8 @@ pub struct AsconXof128 { impl AsconXof128 { /// Creates a new Ascon-XOF128 instance. pub fn new() -> Self { - // Precomputed state after the initialization permutation (SP 800-232 Table 12). + // Precomputed state after the initialization permutation + // (SP 800-232 Table 12). Self { sponge: Sponge::from_state([ 0xDA82CE768D9447EB, 0xCC7CE6C75F1EF969, 0xE7508FD780085631, 0x0EE0EA53416B58CC, @@ -28,15 +37,19 @@ impl AsconXof128 { } } - // Squeeze `output.len()` bytes of output. May be called multiple times; the first call ends the - // absorb phase by padding and absorbing the final block. Returns the number of bytes written. + /// Produces `output.len()` bytes from the XOF stream. + /// + /// The first call ends the absorb phase by padding and absorbing the final + /// message block. Subsequent calls continue the same output stream. fn squeeze_into(&mut self, output: &mut [u8]) -> usize { - let written = output.len(); + output.fill(0); + if !self.sponge.squeezing() { self.sponge.pad_and_absorb(); } + self.sponge.squeeze(output); - written + output.len() } } @@ -51,57 +64,114 @@ impl Algorithm for AsconXof128 { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; } -impl XOF for AsconXof128 { - fn hash_xof(mut self, data: &[u8], result_len: usize) -> Vec { - self.sponge.absorb(data); - let mut out = vec![0u8; result_len]; - self.squeeze_into(&mut out); +/// The output-producing half of [`AsconXof128`]. +/// +/// Calling [`XOF::into_squeezer`] consumes the absorbing `AsconXof128`, so once +/// output begins there is no longer an object on which [`Hash::do_update`] can +/// be called. +#[derive(Clone)] +pub struct AsconXof128Squeezer { + xof: AsconXof128, +} + +impl XOFSqueezer for AsconXof128Squeezer { + fn do_output(&mut self, num_bytes: usize) -> Vec { + let mut out = vec![0u8; num_bytes]; + self.do_output_out(&mut out); out } - fn hash_xof_out(mut self, data: &[u8], output: &mut [u8]) -> usize { - self.sponge.absorb(data); - self.squeeze_into(output) + fn do_output_out(&mut self, output: &mut [u8]) -> usize { + self.xof.squeeze_into(output) + } +} + +impl Hash for AsconXof128 { + /// Ascon-XOF128 absorbs at a rate of 64 bits. + fn block_bitlen(&self) -> usize { + RATE * 8 } - fn absorb(&mut self, data: &[u8]) -> Result<(), HashError> { - if self.sponge.squeezing() { - return Err(HashError::InvalidState( - "Ascon-XOF128 cannot absorb after squeezing has begun", - )); - } - self.sponge.absorb(data); - Ok(()) + /// Nominal digest size used when Ascon-XOF128 is viewed through [`Hash`]. + fn output_len(&self) -> usize { + NOMINAL_OUTPUT_LEN } - fn absorb_last_partial_byte( - &mut self, - _partial_byte: u8, - _num_partial_bits: usize, - ) -> Result<(), HashError> { - Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte input")) + fn hash(mut self, data: &[u8]) -> Vec { + self.do_update(data); + self.do_final() } - fn squeeze(&mut self, num_bytes: usize) -> Vec { - let mut out = vec![0u8; num_bytes]; - self.squeeze_into(&mut out); - out + fn hash_out(mut self, data: &[u8], output: &mut [u8]) -> usize { + self.do_update(data); + self.do_final_out(output) + } + + fn do_update(&mut self, data: &[u8]) { + // A caller-visible AsconXof128 is always in the absorbing phase: + // into_squeezer() consumes it before output can begin. + debug_assert!( + !self.sponge.squeezing(), + "a reachable AsconXof128 must not already be squeezing" + ); + + self.sponge.absorb(data); } - fn squeeze_out(&mut self, output: &mut [u8]) -> usize { - self.squeeze_into(output) + fn do_final(self) -> Vec { + let output_len = self.output_len(); + self.into_squeezer().do_final(output_len) } - fn squeeze_partial_byte_final(self, _num_bits: usize) -> Result { - Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte output")) + fn do_final_out(self, output: &mut [u8]) -> usize { + let output_len = self.output_len(); + let written = output_len.min(output.len()); + + // Hash::do_final_out requires bytes beyond output_len to be zero. + output[written..].fill(0); + + self.into_squeezer().do_final_out(&mut output[..written]) } - fn squeeze_partial_byte_final_out( + fn do_final_partial_bits( self, - _num_bits: usize, - _output: &mut u8, - ) -> Result<(), HashError> { - Err(HashError::InvalidInput("Ascon-XOF128 does not support partial byte output")) + partial_byte: u8, + num_bits: usize, + ) -> Result, HashError> { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-XOF128 does not support partial byte input", + )); + } + + // A zero-bit partial byte means the message is byte-aligned. + let _ = partial_byte; + Ok(self.do_final()) + } + + fn do_final_partial_bits_out( + self, + partial_byte: u8, + num_bits: usize, + output: &mut [u8], + ) -> Result { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-XOF128 does not support partial byte input", + )); + } + + // A zero-bit partial byte means the message is byte-aligned. + let _ = partial_byte; + Ok(self.do_final_out(output)) } fn max_security_strength(&self) -> SecurityStrength { @@ -109,64 +179,153 @@ impl XOF for AsconXof128 { } } -/// Length in bytes of the serialized state of [`AsconXof128`]. -/// Layout: 3-byte library version || 1-byte state tag || 40-byte sponge state (5 × u64 LE) -/// || 8-byte rate buffer || 1-byte buffer position || 1-byte squeezing flag. +impl XOF for AsconXof128 { + type Squeezer = AsconXof128Squeezer; + + fn into_squeezer(self) -> Self::Squeezer { + AsconXof128Squeezer { xof: self } + } + + fn into_squeezer_partial_bits( + self, + partial_byte: u8, + num_bits: usize, + ) -> Result { + if num_bits > 7 { + return Err(HashError::InvalidLength("num_bits must be in the range [0,7]")); + } + + if num_bits != 0 { + return Err(HashError::InvalidInput( + "Ascon-XOF128 does not support partial byte input", + )); + } + + // Per the XOF trait contract, zero partial bits is exactly the + // byte-aligned into_squeezer() operation. + let _ = partial_byte; + Ok(self.into_squeezer()) + } +} + +/// Length in bytes of the serialized Ascon-XOF128 state. +/// +/// Layout: +/// +/// - 3-byte library version +/// - 1-byte state tag +/// - 40-byte sponge state (`5 × u64`, little endian) +/// - 8-byte rate buffer +/// - 1-byte buffer position +/// - 1-byte squeezing flag pub const SUSPENDED_ASCON_XOF128_STATE_LEN: usize = 54; -// Distinguishes an Ascon-XOF128 serialized state from the other (same-shaped) Ascon sponge states. +/// Distinguishes an Ascon-XOF128 serialized state from other Ascon sponge states. const XOF128_STATE_TAG: u8 = 0x02; +/// Deserialize the common sponge representation used by both the absorbing +/// [`AsconXof128`] and squeezing [`AsconXof128Squeezer`] forms. +fn deserialize_sponge( + serialized_state: [u8; SUSPENDED_ASCON_XOF128_STATE_LEN], +) -> Result { + // Infallible: check_lib_ver returns exactly 51 bytes after removing + // the three-byte library-version prefix. + let input: &[u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = + check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + + if input[0] != XOF128_STATE_TAG { + return Err(SuspendableError::InvalidData); + } + + let mut state = Secret::<[u64; 5]>::new(); + + for i in 0..5 { + // Each selected slice is exactly eight bytes. + state[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); + } + + let mut buf = Secret::<[u8; RATE]>::new(); + buf.copy_from_slice(&input[41..49]); + + let buf_pos = input[49] as usize; + + let squeezing = match input[50] { + 0 => false, + 1 => true, + _ => return Err(SuspendableError::InvalidData), + }; + + // While absorbing, a full rate buffer is drained immediately, so the + // position must be strictly less than RATE. During squeezing, RATE is + // allowed to represent "no buffered squeezed byte remains". + let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; + + if !valid_pos { + return Err(SuspendableError::InvalidData); + } + + Ok(Sponge::from_parts(state, buf, buf_pos, squeezing)) +} + +/// Serialize the common sponge representation. +fn serialize_sponge(sponge: &Sponge) -> [u8; SUSPENDED_ASCON_XOF128_STATE_LEN] { + let mut out_to_return = [0u8; SUSPENDED_ASCON_XOF128_STATE_LEN]; + + // Infallible: add_lib_ver returns exactly 51 bytes. + let out: &mut [u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = + add_lib_ver(&mut out_to_return).try_into().unwrap(); + + out[0] = XOF128_STATE_TAG; + + let state = sponge.state_words(); + for i in 0..5 { + out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); + } + + out[41..49].copy_from_slice(&sponge.buf_bytes()); + + debug_assert!(sponge.buf_pos() <= RATE); + out[49] = sponge.buf_pos() as u8; + out[50] = sponge.squeezing() as u8; + + out_to_return +} + impl Suspendable for AsconXof128 { fn suspend(self) -> [u8; SUSPENDED_ASCON_XOF128_STATE_LEN] { - let mut out_to_return = [0u8; SUSPENDED_ASCON_XOF128_STATE_LEN]; - // infallible: add_lib_ver returns a slice of exactly SUSPENDED_ASCON_XOF128_STATE_LEN - 3 = 51 bytes. - let out: &mut [u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = - add_lib_ver(&mut out_to_return).try_into().unwrap(); - - out[0] = XOF128_STATE_TAG; - let state = self.sponge.state_words(); - for i in 0..5 { - out[1 + i * 8..1 + i * 8 + 8].copy_from_slice(&state[i].to_le_bytes()); - } - out[41..49].copy_from_slice(&self.sponge.buf_bytes()); - debug_assert!(self.sponge.buf_pos() <= RATE); - out[49] = self.sponge.buf_pos() as u8; - out[50] = self.sponge.squeezing() as u8; - - out_to_return + serialize_sponge(&self.sponge) } fn from_suspended( serialized_state: [u8; SUSPENDED_ASCON_XOF128_STATE_LEN], ) -> Result { - // infallible: check_lib_ver returns a slice of exactly SUSPENDED_ASCON_XOF128_STATE_LEN - 3 = 51 bytes. - let input: &[u8; SUSPENDED_ASCON_XOF128_STATE_LEN - 3] = - check_lib_ver(&serialized_state, None)?.try_into().unwrap(); + let sponge = deserialize_sponge(serialized_state)?; - if input[0] != XOF128_STATE_TAG { + // The absorbing type must never contain a state that has already + // transitioned into squeezing. Such states belong to the squeezer. + if sponge.squeezing() { return Err(SuspendableError::InvalidData); } - let mut s = Secret::<[u64; 5]>::new(); - for i in 0..5 { - // infallible: each slice is exactly 8 bytes (1+i*8..1+i*8+8) by construction. - s[i] = u64::from_le_bytes(input[1 + i * 8..1 + i * 8 + 8].try_into().unwrap()); - } - let mut buf = Secret::<[u8; RATE]>::new(); - buf.copy_from_slice(&input[41..49]); - let buf_pos = input[49] as usize; - let squeezing = match input[50] { - 0 => false, - 1 => true, - _ => return Err(SuspendableError::InvalidData), - }; - // While absorbing, buf_pos must be < RATE (a full buffer is drained immediately); once - // squeezing, buf_pos may equal RATE (meaning "no leftover squeezed byte buffered"). - let valid_pos = if squeezing { buf_pos <= RATE } else { buf_pos < RATE }; - if !valid_pos { + + Ok(Self { sponge }) + } +} + +impl Suspendable for AsconXof128Squeezer { + fn suspend(self) -> [u8; SUSPENDED_ASCON_XOF128_STATE_LEN] { + serialize_sponge(&self.xof.sponge) + } + + fn from_suspended( + serialized_state: [u8; SUSPENDED_ASCON_XOF128_STATE_LEN], + ) -> Result { + let sponge = deserialize_sponge(serialized_state)?; + + // The squeezer is only valid after the phase transition has happened. + if !sponge.squeezing() { return Err(SuspendableError::InvalidData); } - Ok(AsconXof128 { sponge: Sponge::from_parts(s, buf, buf_pos, squeezing) }) + Ok(Self { xof: AsconXof128 { sponge } }) } } diff --git a/crypto/ascon/tests/bc_test_data.rs b/crypto/ascon/tests/bc_test_data.rs index 01525a94..44305e7a 100644 --- a/crypto/ascon/tests/bc_test_data.rs +++ b/crypto/ascon/tests/bc_test_data.rs @@ -30,13 +30,14 @@ mod bc_test_data { fn get_test_data(filename: &str) -> Result { let found: u8; + if Path::new(TEST_DATA_PATH_RELATIVE).exists() { found = 1; } else if Path::new(TEST_DATA_PATH).exists() { found = 2; } else { found = 3; - }; + } // just print once TEST_DATA_CHECK.call_once(|| match found { @@ -58,6 +59,7 @@ mod bc_test_data { fn decode_hex(value: &str) -> Vec { let clean = value.trim(); + if clean.is_empty() { Vec::new() } else { hex::decode(clean).expect("valid hex") } } @@ -68,27 +70,34 @@ mod bc_test_data { for raw in contents.lines() { let line = raw.trim(); + if line.is_empty() { if !current.is_empty() { cases.push(std::mem::take(&mut current)); } continue; } + if line.starts_with('#') { continue; } + if let Some((key, value)) = line.split_once('=') { let key = key.trim().to_string(); let value = value.trim().to_string(); + if key == "Count" && !current.is_empty() { cases.push(std::mem::take(&mut current)); } + current.insert(key, value); } } + if !current.is_empty() { cases.push(current); } + cases } @@ -98,6 +107,7 @@ mod bc_test_data { return v.as_str(); } } + panic!("missing field {names:?}; case had {:?}", case.keys().collect::>()); } @@ -112,11 +122,13 @@ mod bc_test_data { fn key_material(key: &[u8; 16]) -> KeyMaterial<16> { let mut km = KeyMaterial::<16>::from_bytes_as_type(key, KeyType::SymmetricCipherKey).unwrap(); + do_hazardous_operations(&mut km, |k| { k.set_key_type(KeyType::SymmetricCipherKey)?; k.set_security_strength(SecurityStrength::_128bit) }) .unwrap(); + km } @@ -126,6 +138,7 @@ mod bc_test_data { Ok(c) => c, Err(()) => return, }; + let cases = parse_kat(&contents); assert!(!cases.is_empty(), "no AEAD cases parsed"); @@ -135,29 +148,36 @@ mod bc_test_data { let ad = decode_hex(field(case, &["AD", "A"])); let pt = decode_hex(field(case, &["PT", "P"])); let expected_ct = decode_hex(field(case, &["CT", "C"])); + let ad_opt = if ad.is_empty() { None } else { Some(ad.as_slice()) }; // One-shot encrypt. let mut ct = vec![0u8; pt.len() + 16]; let n = AsconAead128::encrypt(&key, &nonce, ad_opt, &pt, &mut ct).unwrap(); ct.truncate(n); + assert_eq!(ct, expected_ct, "encrypt mismatch (Count {})", field(case, &["Count"])); // One-shot decrypt round-trip. let mut pt_out = vec![0u8; expected_ct.len()]; let m = AsconAead128::decrypt(&key, &nonce, ad_opt, &expected_ct, &mut pt_out) .expect("decrypt should authenticate"); + pt_out.truncate(m); + assert_eq!(pt_out, pt, "decrypt mismatch (Count {})", field(case, &["Count"])); // Byte-at-a-time streaming encrypt/decrypt, through the inherent API. let mut enc = AsconAead128::new(&key, &nonce, ad_opt, true).unwrap(); let mut stream_ct = pt.clone(); + for byte in stream_ct.iter_mut() { enc.do_encrypt_update(core::slice::from_mut(byte)); } + let tag = enc.do_encrypt_final(); stream_ct.extend_from_slice(&tag); + assert_eq!( stream_ct, expected_ct, @@ -167,10 +187,13 @@ mod bc_test_data { let mut dec = AsconAead128::new(&key, &nonce, ad_opt, false).unwrap(); let mut stream_pt = expected_ct[..pt.len()].to_vec(); + for byte in stream_pt.iter_mut() { dec.do_decrypt_update(core::slice::from_mut(byte)); } + dec.do_decrypt_final(&tag).expect("streaming decrypt should authenticate"); + assert_eq!( stream_pt, pt, @@ -178,6 +201,7 @@ mod bc_test_data { field(case, &["Count"]) ); } + println!("Ascon-AEAD128: {} KAT cases passed", cases.len()); } @@ -187,12 +211,14 @@ mod bc_test_data { Ok(c) => c, Err(()) => return, }; + let cases = parse_kat(&contents); assert!(!cases.is_empty(), "no Hash256 cases parsed"); for case in &cases { let msg = decode_hex(field(case, &["Msg"])); let expected = decode_hex(field(case, &["MD"])); + assert_eq!( AsconHash256::digest(&msg).as_slice(), expected.as_slice(), @@ -200,6 +226,7 @@ mod bc_test_data { field(case, &["Count"]) ); } + println!("Ascon-Hash256: {} KAT cases passed", cases.len()); } @@ -209,15 +236,19 @@ mod bc_test_data { Ok(c) => c, Err(()) => return, }; + let cases = parse_kat(&contents); assert!(!cases.is_empty(), "no XOF128 cases parsed"); for case in &cases { let msg = decode_hex(field(case, &["Msg"])); let expected = decode_hex(field(case, &["MD", "Output"])); - let got = AsconXof128::new().hash_xof(&msg, expected.len()); + + let got = AsconXof128::new().xof(&msg, expected.len()); + assert_eq!(got, expected, "XOF128 mismatch (Count {})", field(case, &["Count"])); } + println!("Ascon-XOF128: {} KAT cases passed", cases.len()); } @@ -227,6 +258,7 @@ mod bc_test_data { Ok(c) => c, Err(()) => return, }; + let cases = parse_kat(&contents); assert!(!cases.is_empty(), "no CXOF128 cases parsed"); @@ -234,9 +266,12 @@ mod bc_test_data { let msg = decode_hex(field(case, &["Msg"])); let z = decode_hex(field(case, &["Z", "Customization"])); let expected = decode_hex(field(case, &["MD", "Output"])); - let got = AsconCXof128::with_customization(&z).unwrap().hash_xof(&msg, expected.len()); + + let got = AsconCXof128::with_customization(&z).unwrap().xof(&msg, expected.len()); + assert_eq!(got, expected, "CXOF128 mismatch (Count {})", field(case, &["Count"])); } + println!("Ascon-CXOF128: {} KAT cases passed", cases.len()); } } diff --git a/crypto/ascon/tests/cxof128_tests.rs b/crypto/ascon/tests/cxof128_tests.rs index 5478ba58..bf3ee43b 100644 --- a/crypto/ascon/tests/cxof128_tests.rs +++ b/crypto/ascon/tests/cxof128_tests.rs @@ -1,12 +1,13 @@ //! Ascon-CXOF128 tests (NIST SP 800-232 §5.3). //! //! Embedded NIST LWC known-answer vectors (always-on; full sweep in `bc_test_data.rs`) plus -//! domain-separation, streaming/byte-at-a-time equivalence, trait-API, and misuse-guard tests. +//! domain-separation, streaming/byte-at-a-time equivalence, trait-API, partial-input rejection, +//! and suspend/resume tests. -use bouncycastle_ascon::ascon_cxof128::AsconCXof128; +use bouncycastle_ascon::ascon_cxof128::{AsconCXof128, AsconCXof128Squeezer}; use bouncycastle_ascon::ascon_xof128::AsconXof128; use bouncycastle_core::errors::HashError; -use bouncycastle_core::traits::XOF; +use bouncycastle_core::traits::{Hash, Suspendable, XOF, XOFSqueezer}; use bouncycastle_core_test_framework::xof::TestFrameworkXOF; use bouncycastle_hex as hex; @@ -43,6 +44,7 @@ const CXOF_KAT: &[(&str, &str, &str)] = &[ fn dh(s: &str) -> Vec { let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } } @@ -56,18 +58,22 @@ fn cxof128_embedded_kat() { let msg = dh(msg_hex); let z = dh(z_hex); let expected = dh(md_hex); - let got = AsconCXof128::with_customization(&z).unwrap().hash_xof(&msg, expected.len()); + + let got = AsconCXof128::with_customization(&z).unwrap().xof(&msg, expected.len()); + assert_eq!(got, expected, "msg={msg_hex} z={z_hex}"); - // `AsconCXof128::default()` uses an empty customization string, so the generic XOF - // framework (which constructs via `Default`) only applies to the empty-Z vectors; the - // non-empty-Z vectors are covered by `cxof128_prefix_property_and_streaming` below. + // AsconCXof128::default() uses an empty customization string, so the generic XOF + // framework, which constructs a fresh value itself, only applies directly to empty-Z + // vectors. Non-empty customization is exercised explicitly by the other tests below. if z.is_empty() { - // AsconCXof128 has no absorb_last_partial_byte / squeeze_partial_byte_final support, so - // that part of the framework is disabled; everything else (hash_xof, streaming, prefix - // property, chunked absorb, absorb-after-squeeze) is exercised here. - TestFrameworkXOF { enable_partial_byte_tests: false } - .test_xof::(&msg, &expected); + let mut framework = TestFrameworkXOF::new(); + + // SP 800-232 Ascon-CXOF128 operates on byte strings in this implementation, so + // non-byte-aligned final input is deliberately unsupported. + framework.enable_partial_byte_tests = false; + + framework.test_xof(AsconCXof128::new, &msg, &expected); } } } @@ -76,13 +82,17 @@ fn cxof128_embedded_kat() { fn cxof128_domain_separation() { let msg = pattern(48); - let out_z1 = AsconCXof128::with_customization(b"context-1").unwrap().hash_xof(&msg, 64); - let out_z2 = AsconCXof128::with_customization(b"context-2").unwrap().hash_xof(&msg, 64); + let out_z1 = AsconCXof128::with_customization(b"context-1").unwrap().xof(&msg, 64); + + let out_z2 = AsconCXof128::with_customization(b"context-2").unwrap().xof(&msg, 64); + assert_ne!(out_z1, out_z2, "different customization strings must give different output"); - // Empty-customization CXOF128 must differ from XOF128 (different IV). - let cxof_empty = AsconCXof128::new().hash_xof(&msg, 64); - let xof = AsconXof128::new().hash_xof(&msg, 64); + // Empty-customization CXOF128 must differ from XOF128 because the two functions use + // different initialization/domain separation. + let cxof_empty = AsconCXof128::new().xof(&msg, 64); + let xof = AsconXof128::new().xof(&msg, 64); + assert_ne!(cxof_empty, xof, "CXOF128 (empty Z) must differ from XOF128"); } @@ -90,123 +100,203 @@ fn cxof128_domain_separation() { fn cxof128_prefix_property_and_streaming() { let z = b"cust"; let msg = pattern(70); - let full = AsconCXof128::with_customization(z).unwrap().hash_xof(&msg, 100); - // Squeezing in several calls yields the same stream (prefix property). + let full = AsconCXof128::with_customization(z).unwrap().xof(&msg, 100); + + // Reading from one squeezer in several calls must produce exactly the same continuous + // stream as requesting the whole output in one shot. let mut x = AsconCXof128::with_customization(z).unwrap(); - x.absorb(&msg).unwrap(); + x.do_update(&msg); + let mut squeezer = x.into_squeezer(); + let mut piecewise = Vec::new(); + for n in [30usize, 40, 30] { let mut part = vec![0u8; n]; - x.squeeze_out(&mut part); + let written = squeezer.do_output_out(&mut part); + + assert_eq!(written, n); piecewise.extend_from_slice(&part); } + assert_eq!(piecewise, full, "incremental squeeze must equal a single squeeze"); - // Absorbing in chunks equals one-shot absorb. + // Absorbing the message in chunks must equal absorbing it in one call. for chunk in [1usize, 8, 9, 64] { let mut xc = AsconCXof128::with_customization(z).unwrap(); + for piece in msg.chunks(chunk) { - xc.absorb(piece).unwrap(); + xc.do_update(piece); } + let mut got = vec![0u8; 100]; - xc.squeeze_out(&mut got); + let written = xc.into_squeezer().do_output_out(&mut got); + + assert_eq!(written, got.len()); assert_eq!(got, full, "chunked absorb mismatch (chunk={chunk})"); } } #[test] fn cxof128_byte_at_a_time_matches_one_shot() { - let msg = pattern(40); // > 8 bytes so byte-at-a-time absorb triggers full-block absorption - let cref = AsconCXof128::with_customization(b"zz").unwrap().hash_xof(&msg, 48); + let msg = pattern(40); + + let reference = AsconCXof128::with_customization(b"zz").unwrap().xof(&msg, 48); + let mut c = AsconCXof128::with_customization(b"zz").unwrap(); + for &b in &msg { - c.absorb(&[b]).unwrap(); + c.do_update(&[b]); } - let mut o = [0u8; 48]; - c.squeeze_out(&mut o); - assert_eq!(o.to_vec(), cref, "CXOF128 byte-at-a-time absorb mismatch"); + + let mut out = [0u8; 48]; + let written = c.into_squeezer().do_output_out(&mut out); + + assert_eq!(written, out.len()); + assert_eq!(out.to_vec(), reference, "CXOF128 byte-at-a-time absorb mismatch"); } #[test] -fn cxof128_unsupported_partial_ops_return_err() { - let mut c = AsconCXof128::new(); - assert!(c.absorb_last_partial_byte(0, 3).is_err()); - assert!(AsconCXof128::new().squeeze_partial_byte_final(3).is_err()); - let mut b = 0u8; - assert!(AsconCXof128::new().squeeze_partial_byte_final_out(3, &mut b).is_err()); +fn cxof128_unsupported_partial_input_returns_err() { + // num_bits == 0 means there is no partial byte and must behave exactly like ordinary + // finalization / into_squeezer. + assert!(AsconCXof128::new().into_squeezer_partial_bits(0xFF, 0).is_ok()); + + assert!(AsconCXof128::new().do_final_partial_bits(0x80, 0).is_ok()); + + // Real partial-byte input is deliberately unsupported by Ascon-CXOF128. + assert!(matches!( + AsconCXof128::new().into_squeezer_partial_bits(0xA0, 3), + Err(HashError::InvalidInput(_)) + )); + + assert!(matches!( + AsconCXof128::new().do_final_partial_bits(0xA0, 3), + Err(HashError::InvalidInput(_)) + )); + + let mut out = [0u8; 32]; + + assert!(matches!( + AsconCXof128::new().do_final_partial_bits_out(0xA0, 3, &mut out), + Err(HashError::InvalidInput(_)) + )); + + // More than seven bits is not a partial byte at all. + assert!(matches!( + AsconCXof128::new().into_squeezer_partial_bits(0xFF, 8), + Err(HashError::InvalidLength(_)) + )); } #[test] -fn cxof128_absorb_after_squeeze_errors() { +fn cxof128_absorb_then_squeeze_type_transition() { let mut x = AsconCXof128::with_customization(b"z").unwrap(); - x.absorb(b"data").unwrap(); - let mut out = [0u8; 8]; - x.squeeze_out(&mut out); - // Absorbing after squeezing has begun is reported as an error rather than a panic. - assert!(matches!(x.absorb(b"more"), Err(HashError::InvalidState(_)))); + x.do_update(b"data"); + + let mut squeezer = x.into_squeezer(); + + let first = squeezer.do_output(8); + let second = squeezer.do_output(8); + + let whole = AsconCXof128::with_customization(b"z").unwrap().xof(b"data", 16); + + assert_eq!( + [first, second].concat(), + whole, + "successive reads must continue the same XOF stream" + ); + + // There is deliberately no "absorb after squeeze" runtime test anymore. + // `into_squeezer()` consumes the AsconCXof128, and the returned squeezer does not implement + // Hash::do_update, so that invalid state is prevented by the type system. } #[test] fn cxof128_suspendable_state() { use bouncycastle_core::errors::SuspendableError; - use bouncycastle_core::traits::Suspendable; use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableState; let z = b"customization"; let data: Vec = (0..30u8).collect(); // Reference: uninterrupted absorb + squeeze under the same customization string. - let mut r = AsconCXof128::with_customization(z).unwrap(); - r.absorb(&data).unwrap(); + let mut reference = AsconCXof128::with_customization(z).unwrap(); + reference.do_update(&data); + let mut expected = [0u8; 40]; - r.squeeze_out(&mut expected); + reference.into_squeezer().do_output_out(&mut expected); - // Suspend mid-absorb, resume, finish, and confirm the squeezed output matches. (The - // customization string was already absorbed at construction and is not part of the state.) + // Suspend in the absorbing phase, resume, finish the remaining input, and confirm that + // the output matches the uninterrupted computation. The customization string has already + // been folded into the sponge state at construction time. let mut x = AsconCXof128::with_customization(z).unwrap(); - x.absorb(&data[..5]).unwrap(); + x.do_update(&data[..5]); + TestFrameworkSuspendableState::new().test(&x); let serialized = x.clone().suspend(); + let mut resumed = AsconCXof128::from_suspended(serialized).unwrap(); - resumed.absorb(&data[5..]).unwrap(); + resumed.do_update(&data[5..]); + let mut out = [0u8; 40]; - resumed.squeeze_out(&mut out); + resumed.into_squeezer().do_output_out(&mut out); + assert_eq!(out, expected, "resumed CXOF output must match uninterrupted output"); // A corrupted state tag must be rejected. let mut busted = serialized; busted[3] ^= 0xFF; + assert!(matches!(AsconCXof128::from_suspended(busted), Err(SuspendableError::InvalidData))); - // Cross-type guard: an Ascon-XOF128 state (same serialized length) must be rejected by - // Ascon-CXOF128 via the state tag. + // Cross-type guard: an Ascon-XOF128 state has the same serialized length but a different + // state tag, so Ascon-CXOF128 must reject it. let mut xof = AsconXof128::new(); - xof.absorb(&data).unwrap(); + xof.do_update(&data); + let xof_state = xof.suspend(); + assert!(matches!(AsconCXof128::from_suspended(xof_state), Err(SuspendableError::InvalidData))); - // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) is only - // valid once squeezing has begun. + // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) + // is only valid after squeezing has begun. let mut bad = serialized; let len = bad.len(); - bad[len - 2] = 8; // buf_pos = RATE - bad[len - 1] = 0; // squeezing = false + + bad[len - 2] = 8; + bad[len - 1] = 0; + assert!(matches!(AsconCXof128::from_suspended(bad), Err(SuspendableError::InvalidData))); - // Suspend mid-squeeze (not just mid-absorb) and confirm resuming continues the same stream. + // Suspend after squeezing has actually begun and confirm that restoring the squeezer + // continues the same stream. let mut sq = AsconCXof128::with_customization(z).unwrap(); - sq.absorb(&data).unwrap(); + sq.do_update(&data); + + let mut sq = sq.into_squeezer(); + let mut head = [0u8; 5]; - sq.squeeze_out(&mut head); + sq.do_output_out(&mut head); + let squeezing_state = sq.clone().suspend(); - let mut resumed_sq = AsconCXof128::from_suspended(squeezing_state).unwrap(); + + // A squeezing state belongs to AsconCXof128Squeezer, not the absorbing AsconCXof128 type. + assert!(matches!( + AsconCXof128::from_suspended(squeezing_state), + Err(SuspendableError::InvalidData) + )); + + let mut resumed_sq = AsconCXof128Squeezer::from_suspended(squeezing_state).unwrap(); + let mut tail = [0u8; 35]; - resumed_sq.squeeze_out(&mut tail); + resumed_sq.do_output_out(&mut tail); + let mut combined = Vec::new(); combined.extend_from_slice(&head); combined.extend_from_slice(&tail); + assert_eq!(combined, expected, "resuming mid-squeeze must continue the same output stream"); } @@ -214,8 +304,10 @@ fn cxof128_suspendable_state() { fn cxof128_customization_length_bound() { // SP 800-232 §5.3: the customization string shall be at most 2048 bits (256 bytes). let ok = vec![0u8; 256]; + assert!(AsconCXof128::with_customization(&ok).is_ok()); let too_long = vec![0u8; 257]; + assert!(matches!(AsconCXof128::with_customization(&too_long), Err(HashError::InvalidInput(_)))); } diff --git a/crypto/ascon/tests/xof128_tests.rs b/crypto/ascon/tests/xof128_tests.rs index 22ed9c0a..acc2e768 100644 --- a/crypto/ascon/tests/xof128_tests.rs +++ b/crypto/ascon/tests/xof128_tests.rs @@ -1,11 +1,12 @@ //! Ascon-XOF128 tests (NIST SP 800-232 §5.2). //! //! Embedded NIST LWC known-answer vectors (always-on; full sweep in `bc_test_data.rs`) plus the -//! prefix property, streaming/byte-at-a-time equivalence, trait-API, and misuse-guard tests. +//! prefix property, streaming/byte-at-a-time equivalence, trait-API, partial-input rejection, +//! and suspend/resume tests. -use bouncycastle_ascon::ascon_xof128::AsconXof128; +use bouncycastle_ascon::ascon_xof128::{AsconXof128, AsconXof128Squeezer}; use bouncycastle_core::errors::HashError; -use bouncycastle_core::traits::XOF; +use bouncycastle_core::traits::{Hash, Suspendable, XOF, XOFSqueezer}; use bouncycastle_core_test_framework::xof::TestFrameworkXOF; use bouncycastle_hex as hex; @@ -37,6 +38,7 @@ const XOF_KAT: &[(&str, &str)] = &[ fn dh(s: &str) -> Vec { let s = s.trim(); + if s.is_empty() { Vec::new() } else { hex::decode(s).expect("valid hex") } } @@ -49,135 +51,217 @@ fn xof128_embedded_kat() { for (msg_hex, md_hex) in XOF_KAT { let msg = dh(msg_hex); let expected = dh(md_hex); - let got = AsconXof128::new().hash_xof(&msg, expected.len()); + + let got = AsconXof128::new().xof(&msg, expected.len()); + assert_eq!(got, expected, "msg={msg_hex}"); - // AsconXof128 has no absorb_last_partial_byte / squeeze_partial_byte_final support, so that - // part of the framework is disabled; everything else (hash_xof, streaming, prefix property, - // chunked absorb, absorb-after-squeeze) is exercised here. - TestFrameworkXOF { enable_partial_byte_tests: false } - .test_xof::(&msg, &expected); + + let mut framework = TestFrameworkXOF::new(); + + // This implementation intentionally supports only byte-aligned Ascon-XOF128 input. + framework.enable_partial_byte_tests = false; + + framework.test_xof(AsconXof128::new, &msg, &expected); } } #[test] fn xof128_prefix_property_and_streaming() { let msg = pattern(70); - let full = AsconXof128::new().hash_xof(&msg, 100); - // Squeezing in several calls yields the same stream (prefix property). + let full = AsconXof128::new().xof(&msg, 100); + + // Squeezing in several calls yields the same continuous stream. let mut x = AsconXof128::new(); - x.absorb(&msg).unwrap(); + x.do_update(&msg); + + let mut squeezer = x.into_squeezer(); let mut piecewise = Vec::new(); + for n in [30usize, 40, 30] { let mut part = vec![0u8; n]; - x.squeeze_out(&mut part); + let written = squeezer.do_output_out(&mut part); + + assert_eq!(written, n); piecewise.extend_from_slice(&part); } + assert_eq!(piecewise, full, "incremental squeeze must equal a single squeeze"); - // Absorbing in chunks equals one-shot absorb. + // Absorbing in chunks equals one-shot input. for chunk in [1usize, 8, 9, 64] { let mut xc = AsconXof128::new(); + for piece in msg.chunks(chunk) { - xc.absorb(piece).unwrap(); + xc.do_update(piece); } + let mut got = vec![0u8; 100]; - xc.squeeze_out(&mut got); + let written = xc.into_squeezer().do_output_out(&mut got); + + assert_eq!(written, got.len()); + assert_eq!(got, full, "chunked absorb mismatch (chunk={chunk})"); } } #[test] fn xof128_byte_at_a_time_matches_one_shot() { - let msg = pattern(40); // > 8 bytes so byte-at-a-time absorb triggers full-block absorption - let xref = AsconXof128::new().hash_xof(&msg, 48); + let msg = pattern(40); + + let reference = AsconXof128::new().xof(&msg, 48); + let mut x = AsconXof128::new(); + for &b in &msg { - x.absorb(&[b]).unwrap(); + x.do_update(&[b]); } - let mut o = [0u8; 48]; - x.squeeze_out(&mut o); - assert_eq!(o.to_vec(), xref, "XOF128 byte-at-a-time absorb mismatch"); + + let mut out = [0u8; 48]; + let written = x.into_squeezer().do_output_out(&mut out); + + assert_eq!(written, out.len()); + + assert_eq!(out.to_vec(), reference, "XOF128 byte-at-a-time absorb mismatch"); } #[test] -fn xof128_unsupported_partial_ops_return_err() { - let mut x = AsconXof128::new(); - assert!(x.absorb_last_partial_byte(0, 3).is_err()); - assert!(AsconXof128::new().squeeze_partial_byte_final(3).is_err()); - let mut b = 0u8; - assert!(AsconXof128::new().squeeze_partial_byte_final_out(3, &mut b).is_err()); +fn xof128_unsupported_partial_input_returns_err() { + // num_bits == 0 is byte-aligned input and must behave like ordinary finalization. + assert!(AsconXof128::new().into_squeezer_partial_bits(0xFF, 0).is_ok()); + + assert!(AsconXof128::new().do_final_partial_bits(0x80, 0).is_ok()); + + // Genuine partial-byte input is intentionally unsupported. + assert!(matches!( + AsconXof128::new().into_squeezer_partial_bits(0xA0, 3), + Err(HashError::InvalidInput(_)) + )); + + assert!(matches!( + AsconXof128::new().do_final_partial_bits(0xA0, 3), + Err(HashError::InvalidInput(_)) + )); + + let mut out = [0u8; 32]; + + assert!(matches!( + AsconXof128::new().do_final_partial_bits_out(0xA0, 3, &mut out), + Err(HashError::InvalidInput(_)) + )); + + // Eight bits is not a partial byte. + assert!(matches!( + AsconXof128::new().into_squeezer_partial_bits(0xFF, 8), + Err(HashError::InvalidLength(_)) + )); } #[test] -fn xof128_absorb_after_squeeze_errors() { +fn xof128_absorb_then_squeeze_type_transition() { let mut x = AsconXof128::new(); - x.absorb(b"data").unwrap(); - let mut out = [0u8; 8]; - x.squeeze_out(&mut out); - // Absorbing after squeezing has begun is a usage error; the trait API reports it as an error - // rather than panicking. - assert!(matches!(x.absorb(b"more"), Err(HashError::InvalidState(_)))); + x.do_update(b"data"); + + let mut squeezer = x.into_squeezer(); + + let first = squeezer.do_output(8); + let second = squeezer.do_output(8); + + let whole = AsconXof128::new().xof(b"data", 16); + + assert_eq!( + [first, second].concat(), + whole, + "successive reads must continue the same XOF stream" + ); + + // There is deliberately no runtime "absorb after squeeze" test anymore. + // into_squeezer() consumes AsconXof128, and the resulting squeezer does not implement + // Hash::do_update, so that invalid state cannot be expressed. } #[test] fn xof128_suspendable_state() { use bouncycastle_ascon::ascon_cxof128::AsconCXof128; use bouncycastle_core::errors::SuspendableError; - use bouncycastle_core::traits::Suspendable; use bouncycastle_core_test_framework::suspendable_state::TestFrameworkSuspendableState; let data: Vec = (0..30u8).collect(); // Reference: uninterrupted absorb + squeeze. - let mut r = AsconXof128::new(); - r.absorb(&data).unwrap(); + let mut reference = AsconXof128::new(); + reference.do_update(&data); + let mut expected = [0u8; 40]; - r.squeeze_out(&mut expected); + reference.into_squeezer().do_output_out(&mut expected); // Suspend mid-absorb, resume, finish, and confirm the squeezed output matches. let mut x = AsconXof128::new(); - x.absorb(&data[..5]).unwrap(); + x.do_update(&data[..5]); + TestFrameworkSuspendableState::new().test(&x); let serialized = x.clone().suspend(); + let mut resumed = AsconXof128::from_suspended(serialized).unwrap(); - resumed.absorb(&data[5..]).unwrap(); + resumed.do_update(&data[5..]); + let mut out = [0u8; 40]; - resumed.squeeze_out(&mut out); + resumed.into_squeezer().do_output_out(&mut out); + assert_eq!(out, expected, "resumed XOF output must match uninterrupted output"); // A corrupted state tag must be rejected. let mut busted = serialized; busted[3] ^= 0xFF; + assert!(matches!(AsconXof128::from_suspended(busted), Err(SuspendableError::InvalidData))); - // Cross-type guard: an Ascon-CXOF128 state (same serialized length) must be rejected by - // Ascon-XOF128 via the state tag. + // Cross-type guard: an Ascon-CXOF128 state has the same serialized length but a different + // state tag, so Ascon-XOF128 must reject it. let mut c = AsconCXof128::with_customization(b"z").unwrap(); - c.absorb(&data).unwrap(); + c.do_update(&data); + let c_state = c.suspend(); + assert!(matches!(AsconXof128::from_suspended(c_state), Err(SuspendableError::InvalidData))); - // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) is only - // valid once squeezing has begun. + // An inconsistent buf_pos/squeezing combination must be rejected: buf_pos == RATE (8) + // is only valid once squeezing has begun. let mut bad = serialized; let len = bad.len(); - bad[len - 2] = 8; // buf_pos = RATE - bad[len - 1] = 0; // squeezing = false + + bad[len - 2] = 8; + bad[len - 1] = 0; + assert!(matches!(AsconXof128::from_suspended(bad), Err(SuspendableError::InvalidData))); - // Suspend mid-squeeze (not just mid-absorb) and confirm resuming continues the same stream. + // Suspend after squeezing has begun and confirm that restoring the squeezer continues the + // same stream. let mut sq = AsconXof128::new(); - sq.absorb(&data).unwrap(); + sq.do_update(&data); + + let mut sq = sq.into_squeezer(); + let mut head = [0u8; 5]; - sq.squeeze_out(&mut head); + sq.do_output_out(&mut head); + let squeezing_state = sq.clone().suspend(); - let mut resumed_sq = AsconXof128::from_suspended(squeezing_state).unwrap(); + + // A squeezing state must not be accepted as the absorbing AsconXof128 type. + assert!(matches!( + AsconXof128::from_suspended(squeezing_state), + Err(SuspendableError::InvalidData) + )); + + let mut resumed_sq = AsconXof128Squeezer::from_suspended(squeezing_state).unwrap(); + let mut tail = [0u8; 35]; - resumed_sq.squeeze_out(&mut tail); + resumed_sq.do_output_out(&mut tail); + let mut combined = Vec::new(); combined.extend_from_slice(&head); combined.extend_from_slice(&tail); + assert_eq!(combined, expected, "resuming mid-squeeze must continue the same output stream"); } diff --git a/crypto/factory/src/xof_factory.rs b/crypto/factory/src/xof_factory.rs index 027a64b2..e8eb8495 100644 --- a/crypto/factory/src/xof_factory.rs +++ b/crypto/factory/src/xof_factory.rs @@ -212,9 +212,7 @@ impl Hash for XOFFactory { match self { Self::SHAKE128(h) => h.do_final_partial_bits_out(partial_byte, num_bits, output), Self::SHAKE256(h) => h.do_final_partial_bits_out(partial_byte, num_bits, output), - Self::AsconXof128(h) => { - h.do_final_partial_bits_out(partial_byte, num_bits, output) - } + Self::AsconXof128(h) => h.do_final_partial_bits_out(partial_byte, num_bits, output), } } @@ -244,12 +242,12 @@ impl XOF for XOFFactory { num_bits: usize, ) -> Result { Ok(match self { - Self::SHAKE128(h) => XOFFactorySqueezer::SHAKE128( - h.into_squeezer_partial_bits(partial_byte, num_bits)?, - ), - Self::SHAKE256(h) => XOFFactorySqueezer::SHAKE256( - h.into_squeezer_partial_bits(partial_byte, num_bits)?, - ), + Self::SHAKE128(h) => { + XOFFactorySqueezer::SHAKE128(h.into_squeezer_partial_bits(partial_byte, num_bits)?) + } + Self::SHAKE256(h) => { + XOFFactorySqueezer::SHAKE256(h.into_squeezer_partial_bits(partial_byte, num_bits)?) + } Self::AsconXof128(h) => XOFFactorySqueezer::AsconXof128( h.into_squeezer_partial_bits(partial_byte, num_bits)?, ), @@ -273,4 +271,4 @@ impl XOF for XOFFactory { Self::AsconXof128(h) => h.xof_out(data, output), } } -} \ No newline at end of file +} diff --git a/crypto/factory/tests/xof_factory_tests.rs b/crypto/factory/tests/xof_factory_tests.rs index a3d3d1b7..2dce009e 100644 --- a/crypto/factory/tests/xof_factory_tests.rs +++ b/crypto/factory/tests/xof_factory_tests.rs @@ -53,29 +53,18 @@ fn check_against(make: impl Fn() -> XOFFactory, ctx: &str) { let mut f = make(); f.do_update(MSG); - assert_eq!( - f.do_final_partial_bits(0x05, 3).unwrap(), - expected_bits, - "{ctx}: partial bits" - ); + assert_eq!(f.do_final_partial_bits(0x05, 3).unwrap(), expected_bits, "{ctx}: partial bits"); let mut f = make(); f.do_update(MSG); let mut out = vec![0u8; n]; - assert_eq!( - f.do_final_partial_bits_out(0x05, 3, &mut out).unwrap(), - n, - "{ctx}: ..._out length" - ); + assert_eq!(f.do_final_partial_bits_out(0x05, 3, &mut out).unwrap(), n, "{ctx}: ..._out length"); assert_eq!(out, expected_bits, "{ctx}: do_final_partial_bits_out"); let mut f = make(); f.do_update(MSG); assert!( - matches!( - f.do_final_partial_bits(0xFF, 8), - Err(HashError::InvalidLength(_)) - ), + matches!(f.do_final_partial_bits(0xFF, 8), Err(HashError::InvalidLength(_))), "{ctx}: eight partial bits is not a partial byte" ); @@ -83,11 +72,7 @@ fn check_against(make: impl Fn() -> XOFFactory, ctx: &str) { let mut s = S::default(); s.do_update(MSG); let long = s.into_squeezer().do_output(3 * n); - assert_eq!( - &long[..n], - &expected[..], - "the direct type's hash is a prefix of its stream" - ); + assert_eq!(&long[..n], &expected[..], "the direct type's hash is a prefix of its stream"); let mut f = make(); f.do_update(MSG); @@ -95,71 +80,43 @@ fn check_against(make: impl Fn() -> XOFFactory, ctx: &str) { assert_eq!(fo.do_output(n), &long[..n], "{ctx}: do_output"); let mut buf = vec![0u8; 2 * n]; - assert_eq!( - fo.do_output_out(&mut buf), - 2 * n, - "{ctx}: do_output_out returns the length" - ); + assert_eq!(fo.do_output_out(&mut buf), 2 * n, "{ctx}: do_output_out returns the length"); assert_eq!(buf, &long[n..], "{ctx}: do_output_out continues the stream"); let mut s = S::default(); s.do_update(MSG); - let want = s - .into_squeezer_partial_bits(0x05, 3) - .unwrap() - .do_output(n); + let want = s.into_squeezer_partial_bits(0x05, 3).unwrap().do_output(n); let mut f = make(); f.do_update(MSG); assert_eq!( - f.into_squeezer_partial_bits(0x05, 3) - .unwrap() - .do_output(n), + f.into_squeezer_partial_bits(0x05, 3).unwrap().do_output(n), want, "{ctx}: into_squeezer_partial_bits" ); let mut f = make(); f.do_update(MSG); - assert!(matches!( - f.into_squeezer_partial_bits(0xFF, 8), - Err(HashError::InvalidLength(_)) - )); + assert!(matches!(f.into_squeezer_partial_bits(0xFF, 8), Err(HashError::InvalidLength(_)))); // the one-shots assert_eq!(make().xof(MSG, 3 * n), long, "{ctx}: xof"); let mut out = vec![0xFFu8; 3 * n]; - assert_eq!( - make().xof_out(MSG, &mut out), - 3 * n, - "{ctx}: xof_out returns the length" - ); + assert_eq!(make().xof_out(MSG, &mut out), 3 * n, "{ctx}: xof_out returns the length"); assert_eq!(out, long, "{ctx}: xof_out"); } #[test] fn shake128_by_name_matches_the_direct_type() { - check_against::( - || XOFFactory::new(SHAKE128_NAME).unwrap(), - "SHAKE128 by constant", - ); - check_against::( - || XOFFactory::new("SHAKE128").unwrap(), - "SHAKE128 by string", - ); + check_against::(|| XOFFactory::new(SHAKE128_NAME).unwrap(), "SHAKE128 by constant"); + check_against::(|| XOFFactory::new("SHAKE128").unwrap(), "SHAKE128 by string"); } #[test] fn shake256_by_name_matches_the_direct_type() { - check_against::( - || XOFFactory::new(SHAKE256_NAME).unwrap(), - "SHAKE256 by constant", - ); - check_against::( - || XOFFactory::new("SHAKE256").unwrap(), - "SHAKE256 by string", - ); + check_against::(|| XOFFactory::new(SHAKE256_NAME).unwrap(), "SHAKE256 by constant"); + check_against::(|| XOFFactory::new("SHAKE256").unwrap(), "SHAKE256 by string"); } /// Verify that the Ascon-XOF128 factory registration resolves to the same implementation @@ -193,18 +150,9 @@ fn defaults() { #[test] fn unknown_names_are_refused() { - for name in [ - "SHAKE512", - "shake128", - "", - "cSHAKE128", - "Ascon-XOF999", - ] { + for name in ["SHAKE512", "shake128", "", "cSHAKE128", "Ascon-XOF999"] { assert!( - matches!( - XOFFactory::new(name), - Err(FactoryError::UnsupportedAlgorithm(_)) - ), + matches!(XOFFactory::new(name), Err(FactoryError::UnsupportedAlgorithm(_))), "{name:?} must not construct a XOF" ); } @@ -226,4 +174,4 @@ fn test_framework_xof() { MSG, &SHAKE256::new().xof(MSG, 100), ); -} \ No newline at end of file +} From 465e684b0ba7ac2ada9851cda7b2b157abbbf0a7 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Thu, 17 Sep 2026 20:43:39 +0700 Subject: [PATCH 04/16] Minor doc fix to lib.rs given new XOF api (#119) --- crypto/ascon/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index 661aa6e9..b8be0622 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -15,6 +15,7 @@ //! ``` //! use bouncycastle_ascon::ascon_hash256::AsconHash256; //! use bouncycastle_core::traits::Hash; +//! use bouncycastle_core::traits::XOF; //! //! // One-shot: //! let digest = AsconHash256::digest(b"hello world"); @@ -73,7 +74,7 @@ //! use bouncycastle_ascon::ascon_xof128::AsconXof128; //! use bouncycastle_core::traits::XOF; //! -//! let out = AsconXof128::new().hash_xof(b"input", 64); +//! let out = AsconXof128::new().xof(b"input", 64); //! assert_eq!(out.len(), 64); //! ``` //! From 386bbe3e555f414a12c913750c5247c5b1325932 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 18 Sep 2026 16:50:35 +0700 Subject: [PATCH 05/16] Remediated documentation and test concerns (#119) --- alpha_0.1.3_release_notes.md | 576 +--------------------------- cli/src/ascon_cmd.rs | 94 ++++- cli/src/main.rs | 14 +- cli/tests/ascon_cli_tests.rs | 84 ++-- crypto/ascon/src/lib.rs | 42 +- crypto/ascon/tests/aead128_tests.rs | 5 + crypto/ascon/tests/cxof128_tests.rs | 61 ++- crypto/ascon/tests/xof128_tests.rs | 61 ++- crypto/core/src/tagged_aead.rs | 6 +- crypto/core/src/traits.rs | 7 +- 10 files changed, 291 insertions(+), 659 deletions(-) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 17b6e1fa..a6cbbb50 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -2,561 +2,27 @@ ## Major features -* New algorithms added to crypto/ (PR #89): - * sm3 -- the SM3 hash (GB/T 32905-2016 / ISO/IEC 10118-3:2018), ported from bc-java. Implements `Hash`, - `Suspendable` and `AlgorithmOID`, supports bit-oriented (partial final byte) messages per GB/T 32905-2016 s. 5.2 - with the partial byte in ASN.1 BIT STRING order like SHA-2/SHA-3, and is registered in `HashFactory` - (`"SM3"`) with a `bc-rust sm3` CLI subcommand. - * HMAC-SM3, in the hmac crate, registered in `MACFactory` (`"HMAC-SM3"`) with a `bc-rust hmac-sm3` CLI subcommand. - * Test vectors are the GB/T 32905-2016 Appendix A examples plus the bc-java `SM3DigestTest` / `HMac` vectors, with - additional digests cross-checked against OpenSSL and bc-java. - -New crate `bouncycastle-aes` (`bouncycastle::aes`): AES-128/192/256 as a raw keyed block -permutation (NIST FIPS 197), re-exported from the umbrella crate. - -* **Constant-time and table-free.** The S-box is evaluated as a Boolean circuit -- the 113-gate Boyar-Peralta - straight-line program, 32 AND / 77 XOR / 4 XNOR -- over eight `u32` bit-planes, so there is no secret-indexed - memory access and no secret-dependent branch anywhere, including in the key schedule. A table-driven "light" - AES that removes the tables only from the cipher still leaks through `SUBWORD()` in the expansion. -* **Low memory.** No lookup tables at all (0 bytes, against 512 bytes for BC Java's `AESLightEngine` and 2-8 KiB - for T-table engines) and no heap allocation. The only persistent state is the key schedule, stored bit-sliced - in a compressed form that is exactly the FIPS 197 Sec 5.2 size: `AES_128` 176 B, `AES_192` 208 B, `AES_256` 240 B. -* **Both directions from one value.** Decryption follows FIPS 197 Algorithm 3 (the straight inverse cipher) rather - than the equivalent inverse cipher of Sec 5.3.5, so it uses the unmodified key schedule -- one stored schedule - encrypts and decrypts, with no second copy and no transformation at construction time. -* **Two-block entry points.** The bit-sliced state holds two blocks, so `encrypt_2blocks` / `decrypt_2blocks` are - the natural unit of work and roughly double single-block throughput. `encrypt_block` / `decrypt_block` are - provided but do twice the necessary work; modes whose blocks are independent (CTR, and CBC/CFB decryption) - should prefer the pair form. -* Verified against FIPS 197 Appendix A.1/A.2/A.3 (every schedule word), FIPS 197 Appendix B, an exhaustive check - of all 256 S-box and inverse S-box inputs against Tables 4 and 6, SP 800-38A Appendix F.1 (ECB, all three key - lengths, both directions), and 2138 NIST ACVP `ACVP-AES-ECB` cases from `bc-test-data` (skipped with a warning - if that repository is not checked out). -* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can - only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security - strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. -* Ships the type aliases `AES_CBC_128` / `AES_CBC_192` / `AES_CBC_256`, `AES_CFB_128` / - `AES_CFB_192` / `AES_CFB_256`, `AES_CFB8_128` / `AES_CFB8_192` / `AES_CFB8_256`, - `AES_CTR_128` / `AES_CTR_192` / `AES_CTR_256` (12-byte nonce, 4-byte counter) and - `AES_ECB_128` / `AES_ECB_192` / `AES_ECB_256`, which fill in the - const parameters of `bouncycastle-modes`' `Cbc`, `Cfb`, `Cfb8`, `Ctr` and `Ecb`. The three stream - modes leave the direction as the only type parameter; the two **block** modes, CBC and ECB, take - a padding scheme as well -- `AES_CBC_128` -- because neither is defined on data - that is not a whole number of blocks, so the scheme is a choice the caller has to make and one - both ends must agree on. Naming it in the type makes a mismatched pair a compile error instead of - a decryption that returns plausible rubbish. `PaddedMode` is the crate-internal projection that lets a single - alias carry both parameters, `PaddedEncryptor` and `PaddedDecryptor` being distinct types. They are aliases only -- no new engine - code, and each one's doctest round-trips and shows that a misaligned length fails to compile. - -New crate `bouncycastle-modes` (`bouncycastle::modes`): cipher modes of operation -(NIST SP 800-38A), providing **CBC** (Sec 6.2), **CFB128** and **CFB8** (Sec 6.3, `s = b` and -`s = 8`), **CTR** (Sec 6.5) and **ECB** (Sec 6.1) -- four of the recommendation's five modes, with -only OFB outstanding. Re-exported from the umbrella crate. - -* `Cbc`, `Cfb`, `Cfb8` and `Ecb`, each ``, and `Ctr`, which takes a - nonce length as a fifth parameter, over any - `ElectronicCodeBook`, so the crate depends on no concrete cipher. The direction is a type parameter: - the encryptor trait is implemented only for `<_, Encrypting, _, _>` and the decryptor trait - only for `<_, Decrypting, _, _>`, making a wrong-direction call a compile error rather than a - runtime check. -* **Block modes and stream modes.** `Cbc` and `Ecb` are block ciphers - (`BlockCipherEncryptor` / `BlockCipherDecryptor`): whole blocks in, whole blocks out, with - arbitrary-length data going through `bouncycastle-padding`. `Cfb`, `Cfb8` and `Ctr` are stream - ciphers (`StreamCipherEncryptor` / `StreamCipherDecryptor`): any length in, the same length out, - no padding layer and no finalization step. That split follows SP 800-38A Sec 5.2, which requires a - multiple of the *block* size only for ECB and CBC, a multiple of the *segment* size `s` for CFB, - and nothing at all for CTR ("the plaintext need not be a multiple of the block size"). -* **The IV is generated, never accepted.** SP 800-38A Sec 5.3 requires the CBC *and CFB* IV to be - *unpredictable*, not merely unique, so `do_encrypt_init` draws one from the library's default - OS-backed DRBG (Appendix C's second recommended method) and returns it; there is no API for - supplying your own. Known-answer tests drive `do_encrypt_init_rng` with a fixed-output test RNG. - This matters more for CFB than for CBC: CFB XORs a keystream, so a repeated key-and-IV pair leaks - `P1 XOR P1'` outright rather than merely whether the blocks were equal. -* **Parallel decryption.** Sec 6.2 notes CBC decryption's inverse cipher calls can run in - parallel, so `do_decrypt_blocks` walks the ciphertext in fours through - `ElectronicCodeBook::decrypt_4blocks`, then pairs through `decrypt_2blocks`, then a one-block - remainder. A toy permutation that rotates its four results proves the four path is taken, and - only for full fours. Measured against an - otherwise identical permutation that does not override the pair methods, this is **1.83x** the - decryption throughput (67.9 vs 37.1 MiB/s, AES-128, 16 KiB, N=8). CBC encryption is serial by - construction and does not use it. -* Strictly block-aligned, as Sec 5.2 requires of CBC. Arbitrary-length data goes through - `bouncycastle-padding`'s `PaddedEncryptor` / `PaddedDecryptor`, which wrap either mode; no padding - logic lives in this crate. `crypto/modes/tests/cfb_tests.rs` round-trips every length from 0 to - `3 * BLOCK_LEN + 1` through PKCS7 to pin that the two crates compose. -* Verified against all six SP 800-38A Appendix F.2 vectors (CBC-AES128/192/256, Encrypt and - Decrypt), each checked in one call, one block at a time, in a `3 + 1` grouping that exercises the - pair remainder, and through the `_out` variant. Appendix D error propagation is tested - exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and - for a ciphertext bit error (affects exactly two blocks). -* Also verified against the **2150 NIST ACVP `ACVP-AES-CBC` AFT cases** from `bc-test-data` (all - three key lengths, both directions, 60 of them spanning 2-10 blocks). Each case is run twice -- - block by block, and in pairs with a one-block remainder -- so the `decrypt_2blocks` path is - exercised against real vectors, not only against the toy permutation. Unlike the ECB response - file, the CBC one carries only the answer against a `tcId`, so the request and response files are - joined; the 6 MCT groups are skipped and the count reported. These vectors were already in - `bc-test-data` and previously unused. -CFB128 (`Cfb`), SP 800-38A Sec 6.3 with `s = b`: - -* **A stream cipher.** Sec 6.3 parameterises CFB by a segment size `s` with `1 <= s <= b`, and - `Cfb` implements `s = b` -- CFB128 for AES. With `s = b` the spec's - `LSB_{b-s}(I_{j-1}) | C#_{j-1}` collapses to `Ij = C_{j-1}` and `MSB_s(Oj)` to `Oj`, which the - module docs derive step by step. CFB never puts the data through the cipher, only the input - block, so `Cfb` implements `StreamCipherEncryptor` / `StreamCipherDecryptor`: a `&mut [u8]` of - any length, in place, chunked however the caller likes, with no padding layer. -* **The short final segment.** Sec 5.2 defines CFB only on a multiple of `s`, and Appendix A puts - padding outside the recommendation's scope. Rather than reject a message that is not a whole - number of blocks, `Cfb` takes the `s = 8r` step of the Sec 6.3 equations for the last segment - alone -- `C#_n = P#_n XOR MSB_{8r}(On)` -- discarding the rest of `On` exactly as Sec 6.3 - discards `b - s` bits of every output block when `s < b`. No input block is formed after the last - segment, so the feedback rule that distinguishes `s < b` from `s = b` is never reached and the - result is unambiguous. This is what streaming CFB128 implementations do in practice, and the - ciphertexts interoperate: checked byte for byte against OpenSSL's `EVP_aes_128_cfb128` on a - 37-byte message, in both directions. -* **One buffer, three roles.** Within a segment the single stored block holds the ciphertext - produced so far and the unused tail of `Oj` at once -- each ciphertext byte is written over the - keystream byte that produced it, and is exactly what the next input block wants in that position - -- so the same 16 bytes are the input block, then the output block, then the next input block, - with no copy and no second buffer. That costs one `usize` over `Cbc` (200/232/264 B for - AES-128/192/256) to record how much of the current segment has been used. -* **Decryption uses the forward cipher function.** Sec 6.3 applies `CIPH_K` in both directions, so - `Cfb<_, Decrypting, _, _>` never calls `decrypt_block` or `decrypt_2blocks`. This is pinned by a - test permutation whose inverse methods panic, run over both the pair and single-block paths -- so - the claim is enforced rather than merely documented. -* **Parallel decryption**, via `encrypt_4blocks` / `encrypt_2blocks` (fours, then pairs, then a single block, like CBC): Sec 6.3 notes CFB decryption's forward cipher - calls "can be performed in parallel if the input blocks are first constructed (in series) from the - IV and the ciphertext", and with `s = b` those input blocks simply *are* the IV followed by the - ciphertext. Re-measured after the stream-cipher rewrite: against an otherwise identical - permutation that does not override the pair methods, this is **1.96x** the decryption throughput - (106.8 vs 54.6 MiB/s, AES-128, 16 KiB, N=8). In the same run CFB decryption was **1.26x** CBC - decryption (106.8 vs 84.9 MiB/s), because the bit-sliced engine's forward direction is cheaper - than its inverse and CFB only ever needs the forward one. CFB encryption is serial by - construction and does not use the pair path -- verified, not assumed: the swapped-pair test - permutation produces identical ciphertext under `Cfb` encrypt. -* **The byte path is close to free on encryption and modest on decryption.** Calls that are not a - whole number of blocks end mid-segment and the next call finishes that segment byte by byte. At - 125-byte calls (7 blocks and 13 bytes) encryption measured 51.1 MiB/s against 51.4 for - block-aligned calls, and decryption 90.6 against 106.8 -- the decrypt side pays because a partial - segment at each end of a call breaks the four-block batch. -* Verified against all six SP 800-38A **Appendix F.3.13-F.3.18** vectors (CFB128-AES128/192/256, - Encrypt and Decrypt) in the same four groupings as CBC. F.3 additionally tabulates the *output - blocks* -- the keystream -- so those are checked against the raw permutation too - (`Oj == CIPH_K(I_j)` and `Cj == Pj XOR Oj` for all four segments of all three key lengths), which - pins the mode's internals and not just its final output. As a transcription cross-check, CFB128 - is required to agree with **Appendix F.4.1 (OFB)** on the first block -- both compute - `C1 = P1 XOR CIPH_K(IV)` -- and to disagree from the second. -* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB128` AFT cases** from `bc-test-data` (all - three key lengths, both directions, 54 of them spanning 2-10 blocks), each run in four groupings: - block by block, in pairs with a remainder, as one call over the whole payload, and in 5-byte - calls that never line up with a block, so the byte path is exercised against real vectors with a - segment left open across calls. The 6 MCT groups are skipped and the count reported. These - vectors were already in `bc-test-data` and previously unused. -* Appendix D error propagation is tested in the direction that distinguishes CFB from CBC. Table D.2 - gives CFB "SBE in the decryption of Cj": every one of the 128 bit positions of `C2` is flipped and - required to flip *exactly* that bit of `P2` (the block the attacker aimed at, unlike CBC where it - lands in `P3`), to randomise `P3`, and to leave `P1` and `P4` untouched. The IV case is checked - with real AES, where a corrupted IV must *randomise* `P1` rather than flip a bit in place, and - must not affect any later block -- with `s = b`, Appendix D's "first `i/s` (rounding up)" - segments is one segment for every bit position. -* Mutation-tested: `cargo mutants -p bouncycastle-modes` reports **0 surviving mutants** across - the whole crate (220 mutants, 108 caught, 112 unviable, 0 missed, 0 timed out) -- 45 caught in - `ctr.rs`, 28 in `cfb.rs`, 16 in `cbc.rs`, 14 in `cfb8.rs`, 2 each in `ecb.rs` and `iv.rs` -- - including every `^`-to-`|`/`&` substitution and every keystream-stubbing mutant in the three - keystream modes. One mutant needed the tests to reach past runtime behaviour: stubbing out CTR's - compile-time counter-width guard cannot fail any runtime test, so the `compile_fail` doctests on - `Ctr` are what kill it. -* Still not implemented, and listed in the crate docs: **CFB1** (`s = 1`), whose segment is a - single bit rather than a whole number of bytes and so does not fit a byte-oriented API at all, - and **OFB** and **CTR**. - -CFB8 (`Cfb8`), SP 800-38A Sec 6.3 with `s = 8`: - -* **A different mode, not a variant.** `Cfb8` is its own type, because CFB8 and CFB128 are not - interoperable: they agree on the first byte of ciphertext -- `P1 XOR MSB_8(CIPH_K(IV))` in both -- - and diverge from the second, since `s = b` replaces the whole input block with the ciphertext - block while `s = 8` shifts one byte into a register. Both the type docs and the CLI help say so, - and a test asserts exactly that agree-then-diverge pattern rather than merely that the outputs - differ. -* **The shift register is the spec's own alternative description.** `I_{j+1} = LSB_{b-8}(Ij) | Cj` - is implemented as `rotate_left(1)` followed by writing the ciphertext byte into the last - position, which is Sec 6.3's "the bits of the first input block circularly shift s positions to - the left, and then the ciphertext segment replaces the s least significant bits of the result", - in that order. `MSB_8(Oj)` is the first byte of the output block; the other `b - 8` are - discarded, as Sec 6.3 requires. -* **A stream cipher with a one-byte segment**, so every byte string is a valid message: no - alignment rule, no padding, no partial-segment state. Same size as `Cbc` (192/224/256 B for - AES-128/192/256). -* **One forward cipher per byte.** Discarding 15 of every 16 output bytes is what the mode costs: - encryption measured **3.41 MiB/s** against CFB128's 51.4 on the same data and cipher, a factor of - 15. That is inherent to `s = 8`, and the crate docs, the type docs and the CLI help all say to - prefer `Cfb` unless a byte-granular self-synchronising stream is required or a format demands - CFB8. -* **Decryption still batches.** Sec 6.3's parallel decryption applies: the successive register - states depend only on the IV and the ciphertext, so they are built in series -- byte shuffling, - no cipher calls -- and the forward ciphers then run four at a time through `encrypt_4blocks`, - then in pairs. Measured **1.94x** the throughput of the same decryption in 1-byte calls, which - never batch (6.61 vs 3.40 MiB/s). Encryption cannot batch and does not. -* **Decryption never calls the inverse cipher**, as in CFB128, pinned by the same test permutation - whose inverse methods panic, run over the four-block, pair and single-byte paths. -* Verified against all six SP 800-38A **Appendix F.3.7-F.3.12** vectors (CFB8-AES128/192/256, - Encrypt and Decrypt), each in seven groupings from one byte per call up to the whole message. - F.3.7's tabulated **input and output blocks** -- all 18 of each -- are checked three ways: that - each input block is the previous one shifted with the ciphertext byte appended, that each output - block is `CIPH_K` of it through the raw permutation, and that `Cj == Pj XOR MSB_8(Oj)`. That pins - the register construction against the spec's own table rather than only the final ciphertext. -* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB8` AFT cases** from `bc-test-data` (all - three key lengths, both directions, 60 of them 16 to 160 bytes), each run in four groupings -- - whole message, byte by byte, 8-byte calls and 3-byte calls that never line up with the batch. - The 6 MCT groups are skipped and the count reported. These vectors were already in - `bc-test-data` and previously unused. -* Appendix D error propagation is checked in the form that distinguishes CFB8 from CFB128. Table - D.2 gives "SBE in the decryption of Cj" plus "RBE in ... Cj+1,...,Cj+b/s", and `b/s` is **16** - here rather than 1: with real AES, flipping a ciphertext bit flips exactly that bit of that - plaintext byte, randomises the following 16 bytes, and then decryption **resynchronises - exactly** -- byte `j + 17` onwards is required to be byte-identical to the original plaintext. - That self-synchronisation is the property CFB8 is chosen for, and the equality assertion on the - tail is what pins it. -* Interoperability checked byte for byte against OpenSSL's `EVP_aes_128_cfb8` on a 37-byte message, - in both directions. - -CTR (`Ctr`), SP 800-38A Sec 6.5: - -* **The nonce is the init data, and its length picks the counter width.** Sec 6.5 needs a sequence - of counter blocks that are distinct across every message under a key, and Appendix B.2's second - approach builds each one as a message nonce followed by a counter: "if N is the message nonce for - a given message, then the jth counter block is given by `Tj = N | [j]m`". `Ctr` takes that - literally, splitting the block by the length of its init data: the init data *is* the nonce, and - the remaining `BLOCK_LEN - INIT_DATA_LEN` bytes are the counter. The counter is capped at **4 - bytes** and must be at least 1, both checked at compile time, so on AES the nonce is 12, 13, 14 or - 15 bytes and a wrong one is a compile error rather than a runtime `Err`. -* **The counter starts at zero**, i.e. `Tj = N | [j - 1]m`, one below B.2's `[j]m`. Appendix B - presents B.2 as one of "Two examples of approaches" and closes by allowing "other methods and - approaches for achieving the uniqueness property", so both indexings satisfy the only normative - requirement, that the blocks be distinct. Zero is what makes a nonce-with-zero-counter vector line - up with an implementation handed the whole block as an IV -- which is how the ACVP vectors are - written, and how OpenSSL is driven. -* **Running out of counter is an error, and nothing is consumed.** A `CTR_LEN`-byte counter gives - `2^(8 * CTR_LEN)` blocks -- 64 GiB for a 4-byte counter, 4 KiB for a 1-byte one -- and Appendix - B.1 bounds a message at exactly that ("provided that `n <= 2^m`"). Past it the counter would - repeat, which for a keystream mode is keystream reuse *within one message*. `Ctr` therefore checks - the whole call up front and returns `SymmetricCipherError::StateError` without touching the data, - so a message is never half-encrypted before the mode notices. This is the first and only use in - the crate of the `Result` the data methods have always returned; CBC, CFB, CFB8 and ECB never fail - them. The counter is held as a `u64` rather than as the counter bytes precisely so that exhaustion - is representable: the counter field itself wraps. -* **Both directions are parallel**, the only mode here of which that is true. Sec 6.5: "In both CTR - encryption and CTR decryption, the forward cipher functions can be performed in parallel." - Counter blocks depend on nothing but the nonce and the index, so encryption batches through - `encrypt_4blocks` / `encrypt_2blocks` exactly as decryption does, and encryption and decryption are - the same operation. Only the forward cipher function is ever used, as in the CFB modes. -* The keystream block is the one buffer in this crate wrapped in `Secret`: a call may end part-way - through a block and the remainder is kept for the next one, and unlike a chaining value that - remainder is live key material for the bytes still to come. 224/256/288 B for AES-128/192/256 with - a 12-byte nonce. -* Verified against **1853 of the 2138 NIST ACVP `ACVP-AES-CTR` AFT cases** (all three key lengths, - both directions), each in four groupings. The other 285 begin at a non-zero counter and so cannot - be expressed through a nonce-plus-zero-counter API; they are skipped with the count reported. -* **Every ACVP case is a single block**, so none of them exercises the counter increment at all -- - a mode whose counter never advanced, or advanced little-endian, passes the entire set. (Checked, - not assumed: a deliberately little-endian counter was run against the ACVP suite while these tests - were written, and passed.) Two things close that gap. `ctr_vector_tests.rs` adds five-block - vectors for all three key lengths generated with **OpenSSL 3.0.13**, whose last block is partial - so they also pin Sec 6.5's `MSB_u(On)`; and `ctr_tests.rs` checks the counter blocks against the - raw permutation **at all four counter widths**, across the 255-to-256 carry where the width allows - it. That width sweep matters because the counter occupies a width-dependent slice, and getting it - wrong is invisible to a round-trip test: both directions would build the same wrong block and - still recover the plaintext. -* Cross-checked against **BC Java's `SICBlockCipher`**, which is the closest comparison available: - unlike OpenSSL, whose `-aes-*-ctr` takes the whole block as its IV and so has no notion of a - nonce, `SICBlockCipher` is built the same way -- a short IV goes in the leading bytes, the rest is - zero-filled so the counter starts at 0, it increments big-endian with carry, and it throws - `IllegalStateException("Counter in CTR/SIC mode out of range.")` once the carry would reach the - IV. Same construction, same start, same overflow rule; the only difference is that BC Java caps - the counter at `min(8, blockSize / 2)` bytes where this type stops at 4, so ours is a subset and - the two agree exactly on nonces of 12 to 15 bytes. Agreement is byte for byte on the 69-byte - vectors and on a 5000-byte message across the 255-to-256 carry at all three key lengths, and the - counter limit falls on the same byte at both the 1-byte (4 KiB) and 2-byte (1 MiB) widths. - `ctr_bc_java_tests.rs` pins what neither the ACVP nor the OpenSSL suite can reach: the keystream - at **1, 2 and 3-byte counters**, including both ends of the 1-byte counter's range and the - 2-byte counter's carry from block 255 to 256. -* SP 800-38A **Appendix F.5** is not transcribed: its vectors start the counter at `0xfcfdfeff` - rather than zero, so they cannot be expressed through this API. What F.5 does corroborate is the - split -- across its four blocks the counter moves only within the last four bytes, leaving the - leading twelve fixed -- and a test pins that reading. -* The counter limit is tested at two widths: a 1-byte counter (256 blocks, 4 KiB) and a 2-byte one - (65536 blocks, 1 MiB), in both directions, including that a refused call leaves the data and the - counter untouched so the bytes that do fit are unaffected by the attempt. - -`cli`: twelve new subcommands -- `aes{128,192,256}-cbc`, `-cfb`, `-cfb8` and `-ctr` -- each taking -`encrypt` or `decrypt` and streaming stdin to stdout in 1 KiB chunks. - -* The mode-independent plumbing lives once, in two halves that share their key loading and their - `encrypt` / `decrypt` spelling. `cli/src/block_mode_cmd.rs` holds the block half -- stdin framing - with block-alignment enforcement, hex/binary output -- generic over `BlockCipherEncryptor` / - `BlockCipherDecryptor`; `cli/src/stream_mode_cmd.rs` holds the stream half, generic over - `StreamCipherEncryptor` / `StreamCipherDecryptor`, which buffers nothing to a boundary and - rejects no length. `aes_cbc_cmd.rs`, `aes_ecb_cmd.rs`, `aes_cfb_cmd.rs` and `aes_cfb8_cmd.rs` are - thin dispatchers, so the commands cannot drift apart on the parts that affect correctness. -* Key from `--key` (hex) or `--key-file` (binary or hex), with the usual note that secrets on the - command line end up in shell history. The key length must match the variant exactly. -* **The IV travels in the ciphertext**: since there is no API for supplying one, `encrypt` writes - the generated IV as the first 16 bytes of its output and `decrypt` reads it back from the first - 16 bytes of its input, so `encrypt | decrypt` composes with no `--iv` flag anywhere. The IV need - not be secret (SP 800-38A Sec 5.3), so this is sound. -* Input to the `-cbc` and `-ecb` commands must be a whole number of 16-byte blocks; unaligned input - is rejected with a message saying the commands apply no padding rather than being silently - padded. The `-cfb` and `-cfb8` commands take **any length** and pad nothing, because they are - stream ciphers; their output is exactly as long as their input. -* The `-cfb` commands are **CFB128** and the `-cfb8` commands are **CFB8**, and every subcommand's - help names its segment size and says the two are not interoperable, because they would otherwise - silently produce incompatible output. -* The `-ctr` commands write a **12-byte nonce**, not the 16-byte IV every other mode writes, so - their output is 12 bytes longer than their input rather than 16. The per-command help says so, and - `cli/tests/aes_ctr_cli_tests.rs` (21 tests) pins it along with the OpenSSL vectors end to end, - CTR's total malleability (a flipped ciphertext bit flips exactly one plaintext bit and disturbs - nothing else), and that a CFB command cannot read a CTR ciphertext. -* Reads need not respect block boundaries: bytes accumulate in a 1 KiB buffer that goes through the flat - `do_*_out::<1024>` when full, and the whole-block remainder at end of input goes one block at a time; verified by - round-tripping 64 KiB through `dd bs=3`. -* Verified against SP 800-38A F.2 (CBC), F.3.13/F.3.15/F.3.17 (CFB128) and F.3.7/F.3.9/F.3.11 - (CFB8): prepending the spec's IV to the spec's ciphertext and running `decrypt` reproduces the - spec's plaintext for all three key lengths in every mode. The `encrypt` direction was - cross-checked against OpenSSL under the IV the CLI generated -- for CBC, and for both CFB modes - on a 37-byte (deliberately unaligned) message, where our ciphertext and `openssl enc - -aes-128-cfb` / `-aes-128-cfb8` agree byte for byte and each tool decrypts the other's output. -* `cli/tests/aes_cbc_cli_tests.rs` (16 tests) drives the built binary as a subprocess via - `CARGO_BIN_EXE_bc-rust`, so all of the above is asserted by `cargo test` rather than by hand: - the F.2 vectors, round trips across the chunk boundary, a fresh IV per invocation, hex/binary - agreement, `--key-file` in both hex and binary, and every error path with its message. -* `cli/tests/aes_cfb_cli_tests.rs` (21 tests) mirrors that suite -- the shared plumbing is generic - over the mode, so a wiring mistake in the CFB dispatcher would not show up in the CBC tests -- and - adds four CFB-specific checks: the F.3 vectors, the Appendix D single-bit malleability observed - end to end through the pipe, a guard that a CFB ciphertext does not decrypt as CBC or vice - versa (neither mode is authenticated, so the mismatch is otherwise silent), and that every length - from 0 to 33 bytes round-trips with the ciphertext exactly as long as the plaintext. -* `cli/tests/aes_cfb8_cli_tests.rs` (19 tests) does the same for CFB8, including the F.3.7/9/11 - vectors, every length from 0 to 33 bytes, and the Appendix D window: a flipped ciphertext bit - flips the same bit of the same plaintext byte, corrupts the next 16 bytes, and then the output is - required to be byte-identical to the original again. - -ECB (`Ecb`), SP 800-38A Sec 6.1: - -* **The raw permutation with the mode API, for interoperability only.** `Ecb` implements - `BlockCipherEncryptor` / `BlockCipherDecryptor` with `INIT_DATA_LEN = 0`: `do_encrypt_init` returns an empty array and - draws nothing from the RNG, `do_decrypt_init` takes one. Same direction typing, streaming and one-shot methods, - compile-time length checks and padding-layer composition as `Cbc` / `Cfb`, so a key-wrapping scheme, a legacy protocol - or a test-vector harness that needs ECB can use it through the same interface. The crate docs, the type docs and the - CLI help all say the same thing about it: **not a confidentiality mode for data** (Sec 6.1: "any given plaintext block - always gets encrypted to the same ciphertext block"). One block smaller than `Cbc` / `Cfb`, since nothing chains - (176 / 208 / 240 B for AES-128/192/256). -* **Both directions batch.** Sec 6.1 allows forward and inverse cipher calls "to be computed in parallel", so encryption - as well as decryption walks the blocks through `ElectronicCodeBook::{en,de}crypt_4blocks`, then the pair methods, then - a single block. The swapped-pair and rotated-four test permutations prove both paths are taken in both directions. -* `aes128-ecb` / `aes192-ecb` / `aes256-ecb` CLI subcommands over the shared block-mode plumbing, which is now generic - over `INIT_DATA_LEN`: nothing is prepended on `encrypt` or consumed on `decrypt`, so output is exactly as long as - input. The per-command help carries the warning. -* Verified against all six SP 800-38A **Appendix F.1** vectors (ECB-AES128/192/256, Encrypt and Decrypt) in five - groupings each -- and, since there is no IV, `encrypt` is checked against the published ciphertext too, through the - streaming API and the one-shot. Each tabulated ciphertext block is also checked to be `CIPH_K` of its plaintext block - through the raw permutation. The **NIST ACVP `ACVP-AES-ECB`** set (2138 AFT cases) already used by `aes` - is run again through the mode API, both directions, in three groupings including one that reaches the four-block - path. Structural tests pin the Sec 6.1 equations against a reference over the toy permutation, determinism and the - codebook property, Appendix D error propagation (a corrupted block randomises itself and nothing else, checked over - all 128 bit positions with real AES), the empty init data, and composition with `bouncycastle-padding`. - -`core`: new `ElectronicCodeBook` trait (`crypto/core/src/traits.rs`), the raw -keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. -`new`, `encrypt_block`, `decrypt_block`, plus provided `encrypt_2blocks` / `decrypt_2blocks` that -default to two single-block calls and `encrypt_4blocks` / `decrypt_4blocks` that default to two pair -calls, all of which bit-sliced implementations override (AES the pair form, SM4 both). The block methods -are infallible; only `new` can fail, and only on the key. `bouncycastle-aes` implements -it for all three key lengths (the data-encryption traits are still deliberately not implemented -there). - -`core`: new `SimpleCipherEncryptor` and -`SimpleCipherDecryptor` traits, the arbitrary-length data API a -caller uses, as opposed to the block-aligned `BlockCipher*` traits a mode implements. Their shape is -taken from `PaddedEncryptor` / `PaddedDecryptor`, which now implement them: streaming -`do_{en,de}crypt_init[_rng]`, exact `update_out_len`, `do_update_out`, and a consuming `do_final` that -returns the `FINAL_LEN` trailing buffer (the padded block; a tag for an AEAD) paired with how many of its -bytes are output -- always `FINAL_LEN` except for a padding scheme that adds nothing to aligned data -- -and, for the decryptor, how many of them are data. `do_final_out`, the `_out` one-shots -(`encrypt_out[_rng]`, `decrypt_out`, with `encrypt_out_len` exact and `decrypt_out_max_len` an upper -bound, checked before any work is done) and the `std` `Vec` one-shots are provided over the streaming -methods, so an implementor writes six methods. - -The older one-shot-only `SymmetricCipher` trait is **deleted**, and its four methods -- `encrypt`, -`encrypt_out`, `decrypt`, `decrypt_out` -- move onto `AEADCipher`, which was its only remaining -user. Every other kind of cipher now reaches an arbitrary-length one-shot some other way: a block -mode through `SimpleCipherEncryptor` / `SimpleCipherDecryptor` and the padding adapters, a -stream mode through those same traits directly. `AEADCipher` therefore drops the supertrait and -declares the four itself, against `NONCE_LEN`, with the documentation saying what they mean for an -AEAD: no additional authenticated data, and a ciphertext layout that is the implementation's -business because the tag has to go somewhere. `TestFrameworkSimpleCipher::test`, which was that -trait's suite, moves to `TestFrameworkAEADCipher::test_plain_one_shots` and is called from -`TestFrameworkAEADCipher::test`, so an AEAD implementor keeps the coverage without asking for it. - -That move also closed the last of a latent bug recorded in `core-test-framework/summary.md`: two -security-strength loops unwrapped `set_security_strength` at all five strengths, which a key shorter -than 32 bytes cannot carry, so they would have panicked for the first AEAD implementor — ASCON-128 -and AES-128-GCM among them. Relocating one of them into a method the AEAD suite calls would have -made that worse, so both now carry the same key-length guard the block and stream suites already -had. Every strength loop in the file is guarded. - -Stream ciphers also reach the arbitrary-length API: `StreamCipherEncryptor` and -`StreamCipherDecryptor` get blanket impls of `SimpleCipherEncryptor` / `SimpleCipherDecryptor` -with `FINAL_LEN = 0`, written in terms of the in-place `do_encrypt` / `do_decrypt`. An implementor -still writes only the in-place methods, but a caller can use `encrypt_out`, `do_update_out` and the -`std` one-shots, and can hold a stream mode through the same trait as a padded block mode -- which -is what makes "any of the five modes behind one trait" true rather than aspirational. For a stream -cipher the length predictions are exact rather than upper bounds, and `do_final` has nothing to -produce. The one cost is that both traits then spell `do_encrypt_init` identically, so code with -both in scope must qualify the call; `crypto/modes/tests/simple_cipher_api_tests.rs` is written -that way deliberately, to show it is workable. That file also runs all three stream modes through -`TestFrameworkSimpleCipher::test_encryptor_decryptor`, the same conformance suite the padded -adapters run, and checks the separate-output API against the in-place one byte for byte. - -Mutation-tested with `--test-workspace`, which is what these blanket impls need: run against core's -own tests alone they look untested, because core has no implementors of its own traits. Scoped to -the change, 45 mutants, 22 caught, 19 unviable, 4 missed -- all four the same equivalent mutant, -`[]` against `[0; 0]` and `[1; 0]` for a zero-length array, which no test can distinguish because -they are the same value; both sites carry a comment saying so. The one genuinely uncovered mutant -the run found, the decryptor's output-buffer length comparison, is now covered. - -`StreamCipher` is **replaced** by the split pair `StreamCipherEncryptor` / `StreamCipherDecryptor`, -shaped like `BlockCipherEncryptor` / `BlockCipherDecryptor` and for the same reasons: the direction -is encoded in the type, and a policy can permit decryption of an algorithm while forbidding new -encryptions. The old trait carried both directions and a `BLOCK_LEN` const parameter on every data -method, which a stream cipher has no use for; the new pair takes a `&mut [u8]` of any length, works -in place, generates its own init data in the constructor (never accepting one), and provides its -one-shots over a single implementor hook per direction. `Cfb` and `Cfb8` are its first implementors. - -Testing: - -* `core-test-framework` gains `TestFrameworkSimpleCipher::test_encryptor_decryptor`, which pins the - paired contract: one-shot round trips at every length up to a few final chunks, the `std` one-shots - against the `_out` ones, streaming in eight chunkings with `update_out_len` exact on every call, - `do_final_out` against `do_final`, a driven RNG reproducing its init data and determining the - ciphertext, corruption detection, short output buffers refused with the required length, and the - key-type and security-strength policy. The padded adapters run it. -* `core-test-framework` gains `TestFrameworkElectronicCodeBook`, which pins the trait contract: - both directions are inverses either way round, the permutation is injective, and the pair - methods are indistinguishable from two single-block calls **including their order** -- the check - that makes an override safe. -* Fixed a latent bug in `TestFrameworkBlockCipher`: it unwrapped `set_security_strength` at all - five strengths, which a key shorter than 32 bytes cannot carry, so the framework panicked for - any 16- or 24-byte key. It now skips the strengths the key length cannot hold. The bug was - invisible until now because nothing in the workspace implemented the block cipher traits. The - identical loop in `TestFrameworkSimpleCipher` and `TestFrameworkAEADCipher` got the same fix in - the same PR, and each also gained a `strengths_tested > 0` assertion so the sweep cannot silently - become vacuous again. `bouncycastle-ascon`'s `AsconAead128Encryptor`/`AsconAead128Decryptor` - (16-byte key) are now the first implementors to actually exercise the AEAD suite's guard. -* `TestFrameworkStreamCipher::test` was a `todo!()` and is now implemented for the - `StreamCipherEncryptor` / `StreamCipherDecryptor` pair, carrying the same key-length guard as the - block suite from the start. It pins the paired contract: one-shot round trips, streaming in nine - chunkings checked against the one-shot and against every other chunking (including empty calls, - so a call may end mid-segment), the RNG-taking constructors reproducing their init data and - determining the ciphertext, distinct init data across runs, the wrong key type rejected in both - directions, and the security-strength policy. `Cfb` and `Cfb8` both run it. - -* Block cipher padding (PR #97): - * padding -- new crate (`bouncycastle-padding`, no_std, re-exported as `bouncycastle::padding`) providing `PKCS7`, - the padding scheme of RFC 5652 s. 6.3, for any block length 1..=255 (enforced at compile time). `unpad` examines - every byte with `Condition` mask arithmetic and has a single public decision point, so it does not leak a - padding oracle through timing or error detail. - * `PaddedEncryptor` / `PaddedDecryptor` adapt a block-aligned `BlockCipherEncryptor` / - `BlockCipherDecryptor` to arbitrary-length data: streaming `do_update_out` / `do_final(self)` plus one-shot - `encrypt_out` / `decrypt_out`, with exact output-length helpers. The buffered partial plaintext block is held in - a `Secret`, and the decryptor withholds one complete block until `do_final`, since only the last block carries - padding. - * `core` gains the `Padding` trait (in-place `pad(block, data_len)`, constant-time - `unpad(block) -> data_len`, and `ALWAYS_PADS`, whether the scheme appends a block to already-aligned data) and - `PaddingError { DataLengthTooLong, InvalidPadding, PaddingNotPermitted }`, wrapped as a new variant of - `SymmetricCipherError`. - * `NoPadding`: the absence of padding as a `Padding` scheme, for data that must already be a whole number of - blocks. `pad` never writes a byte and returns `PaddingNotPermitted` whenever called; `unpad` reports the whole - block as data; `ALWAYS_PADS` is false. Through `PaddedEncryptor` / `PaddedDecryptor` this *enforces* alignment - with the arbitrary-length API shape: an aligned message passes through with its length unchanged and no final - block, an unaligned one fails at `do_final` / `encrypt_out`, and an empty ciphertext decrypts to the empty - message. The test framework's `TestFrameworkSimpleCipher` gained `required_alignment`, which makes it assert - that every unaligned length is refused. - * Tests are derived from the RFC 5652 padding rule; the adapters are driven with a toy XOR-CBC cipher implementing - the new block cipher traits, covering every data length, ten chunkings in both directions, tampering, malformed - lengths, and buffer sizing. Criterion bench included. - -`core`: new `AEADCipherEncryptor` and -`AEADCipherDecryptor` traits (#119/#120), the streaming API -for an authenticated cipher, shaped like `SimpleCipherEncryptor` / `SimpleCipherDecryptor` (separate -input/output buffers, exact `update_out_len`, generated nonce) with the two things authentication -adds: an AAD phase (`do_update_aad`, repeatable before the first `do_update_out`, refused with -`StateError` once data has started) and a finalizer that also produces the tag -(`do_encrypt_final`/`do_decrypt_final`, flushing up to `FINAL_LEN` held-back bytes alongside it). -`FINAL_LEN` is `0` for a cipher like Ascon-AEAD128 that never buffers; a block-oriented AEAD or one -whose wire format inlines the tag would need it non-zero. The one-shots (`encrypt_out[_rng]`, -`decrypt_out`, and the `std` `Vec` forms) are provided over the streaming methods, so an implementor -writes seven. `bouncycastle-ascon`'s `AsconAead128Encryptor` / `AsconAead128Decryptor` are the first -implementors. - -Mutation-tested with `cargo mutants -p bouncycastle-core -F 'AEADCipher(Encryptor|Decryptor)' ---test-package bouncycastle-ascon` (`core` has no implementor of its own to test against): 68 -mutants, 49 caught, 10 unviable, 9 missed -- all nine equivalent given `FINAL_LEN = 0`, the only -value Ascon-AEAD128 exercises. Six are `written + final_len` vs `written - final_len` in -`encrypt_out`/`encrypt_out_rng`/`decrypt_out`'s final-buffer splice, indistinguishable because -`final_len` is always `0` there; the other three are the one-shots' own buffer-length guard -(`plaintext.len() < needed` / `ciphertext.len() < needed`) against `>`, indistinguishable because -`needed` at `FINAL_LEN = 0` is exactly the bound Ascon's own `do_update_out` already enforces one -call deeper, so the outer guard's direction is never the only thing standing between a short buffer -and an error. A future `FINAL_LEN > 0` implementor (a block-oriented AEAD) would give both classes -of mutant something to bite on. - -Where the tag goes is deliberately not fixed by the pair (contrast `AEADCipher`, whose one-shots -pick a layout): `core::tagged_aead::TaggedEncryptor` / `TaggedDecryptor` adapt any -`FINAL_LEN = 0` implementor to `SimpleCipherEncryptor` / `SimpleCipherDecryptor`, producing and -consuming the inline `ciphertext || tag` layout most wire formats and files use, with the AAD phase -still reachable through an inherent `do_update_aad` the `SimpleCipher*` traits have no slot for. -`TaggedDecryptor` holds back exactly the last `TAG_LEN` bytes it has seen at any point, releasing -everything older through the wrapped decryptor as soon as it is known not to be the tag -- the same -technique `bc-rust`'s `ascon-aead128 --decrypt` used by hand before this adapter existed, now -provided once. (A fully general adapter over a implementor whose own `FINAL_LEN` is non-zero needs -this adapter's `FINAL_LEN` to be `INNER_FINAL_LEN + TAG_LEN`, a value derived from two other const -generics that stable const generics cannot express as a trait argument; left to a future adapter.) - -New crate `bouncycastle-ascon` (`bouncycastle::ascon`): Ascon-AEAD128 / Ascon-Hash256 / Ascon-XOF128 -/ Ascon-CXOF128 (NIST SP 800-232), the lightweight cryptography suite selected from the NIST -Lightweight Cryptography competition. - -* `AsconAead128` is the streaming primitive (rate 128 bits, capacity 192 bits, `Ascon-p[12]` at - init/finalization and `Ascon-p[8]` on AAD/data blocks), with a caller-supplied nonce for KAT and - protocol use. Every plaintext/ciphertext byte is transformed and emitted the moment it is seen -- - no held-back buffering across calls -- because within a rate block each byte is independent of - the others in it; this is what lets its finalizers have nothing left to flush. - `AsconAead128Encryptor` / `AsconAead128Decryptor` are thin newtypes over it implementing the new - `AEADCipherEncryptor` / `AEADCipherDecryptor` pair with an internally-generated nonce; `AsconAead128` - itself keeps implementing the one-shot-only `AEADCipher` (both directions on one type, chosen by a - runtime flag), which the newtype split cannot replace since that trait needs both directions - available on a single implementor. -* `AsconHash256` (`Hash`) and `AsconXof128` (`XOF`) are sponge constructions over the same - permutation; `AsconCXof128` (`XOF`) adds the customization string of SP 800-232 Algorithm 7 (up to - 256 bytes). All four are byte-oriented: `do_final_partial_bits`/the equivalent XOF methods always - return an error rather than accept a partial final byte, unlike SHA-2/SHA-3. Registered in - `HashFactory` (`"Ascon-Hash256"`) and `XOFFactory` (`"Ascon-XOF128"`), with `ascon-hash256`, - `ascon-xof128`, `ascon-cxof128` and `ascon-aead128` CLI subcommands; the last streams both - directions in 1 KiB chunks, decrypting through `TaggedDecryptor` rather than a hand-rolled tail - buffer. -* **Decryption releases plaintext before the tag is checked**, streaming or through the CLI: bytes - are necessarily written to the caller's buffer (or stdout) before the last `TAG_LEN` bytes -- the - tag -- can be read and compared. A non-zero exit from the CLI, or an `Err` from the streaming - finalizer, means the input was tampered with and any output already produced must be discarded; - do not treat it as authentic before that point. The one-shot APIs (`AsconAead128::decrypt`, both - `AEADCipher` and `AEADCipherDecryptor` views) do not have this caveat: they own the whole message - and zeroize the output buffer before returning an error. -* Verified against 4228 NIST LWC KAT vectors from `bc-test-data` (1089 each for AEAD128 and - CXOF128, 1025 each for Hash256 and XOF128), plus embedded always-on vectors for when that - repository is not checked out. Mutation-tested with `cargo mutants -p bouncycastle-ascon`: 665 - mutants, 558 caught, 103 unviable, 4 missed -- all four the same equivalent survivors as the - crate's introduction (PR #21): the `Sponge::absorb`/`squeeze` boundary pair and the disjoint-bit - `set_state_byte` OR-vs-XOR pair, neither touched by the `AEADCipherEncryptor`/`AEADCipherDecryptor` - work. +* New algorithms added to crypto/ : + * SM3 -- the SM3 hash (GB/T 32905-2016 / ISO/IEC 10118-3:2018), ported from bc-java. + * AES -- AES-128/192/256, along with its modes AES_ECB, AES_CBC, AES_GCM. + * ASCON -- Ascon-AEAD128, Ascon-Hash256, Ascon-XOF128 and Ascon-CXOF128 (NIST SP 800-232). + `AsconAead128Encryptor` / `AsconAead128Decryptor` implement the generated-nonce + `AEADCipherEncryptor` / `AEADCipherDecryptor` pair, and `core::tagged_aead` adapts a + detached-tag AEAD to the common `ciphertext || tag` layout. + * `bouncycastle-ascon` is re-exported as `bouncycastle::ascon`; `Ascon-Hash256` and + `Ascon-XOF128` are registered in the factories, and the CLI adds `ascon-hash256`, + `ascon-xof128`, `ascon-cxof128` and `ascon-aead128`. The AEAD command generates and prefixes + the nonce by default, with `--nonce`/`--nonce-file` retained for deterministic vectors. + Streaming decrypt releases plaintext before the final tag check, so callers must discard any + output if finalization or the CLI exit status reports authentication failure. + * `core` gains the streaming AEAD split: `AEADCipherEncryptor` and `AEADCipherDecryptor<...>`, with AAD updates, exact `update_out_len`, + detached tags, one-shot helpers and a `FINAL_LEN` flush buffer for implementations that hold + data back. + * Testing covers the ASCON NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 + Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus + embedded always-on vectors. Mutation testing for `bouncycastle-ascon` currently reports 735 + mutants, 604 caught, 111 unviable and 20 missed before the XOF/CXOF boundary-test additions. ## Minor features / bug fixes diff --git a/cli/src/ascon_cmd.rs b/cli/src/ascon_cmd.rs index 49ca5297..64bbf3fd 100644 --- a/cli/src/ascon_cmd.rs +++ b/cli/src/ascon_cmd.rs @@ -1,7 +1,9 @@ use std::io::{self, Read}; use std::process::exit; -use bouncycastle::ascon::ascon_aead128::{AsconAead128, AsconAead128Decryptor}; +use bouncycastle::ascon::ascon_aead128::{ + AsconAead128, AsconAead128Decryptor, AsconAead128Encryptor, +}; use bouncycastle::ascon::ascon_cxof128::AsconCXof128; use bouncycastle::ascon::ascon_hash256::AsconHash256; use bouncycastle::ascon::ascon_xof128::AsconXof128; @@ -9,8 +11,8 @@ use bouncycastle::core::errors::SymmetricCipherError; use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::tagged_aead::TaggedDecryptor; -use bouncycastle::core::traits::{SecurityStrength, SimpleCipherDecryptor}; +use bouncycastle::core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; +use bouncycastle::core::traits::{SecurityStrength, SimpleCipherDecryptor, SimpleCipherEncryptor}; use bouncycastle::hex; use crate::helpers; @@ -30,6 +32,23 @@ fn load_bytes(value: &Option, value_file: &Option, label: &str) } } +fn load_optional_bytes( + value: &Option, + value_file: &Option, + label: &str, +) -> Option> { + if let Some(file) = value_file { + Some(helpers::read_from_file(file)) + } else { + value.as_ref().map(|v| { + hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: {label} is not valid hex."); + exit(-1) + }) + }) + } +} + fn require_16(bytes: Vec, label: &str) -> [u8; 16] { bytes.try_into().unwrap_or_else(|_: Vec| { eprintln!("Error: {label} must be exactly 16 bytes."); @@ -99,7 +118,8 @@ pub(crate) fn aead128_cmd( output_hex: bool, ) { let key = load_key_material(&require_16(load_bytes(key, key_file, "key"), "key")); - let nonce = require_16(load_bytes(nonce, nonce_file, "nonce"), "nonce"); + let nonce = + load_optional_bytes(nonce, nonce_file, "nonce").map(|bytes| require_16(bytes, "nonce")); let ad_bytes = match ad { Some(v) => hex::decode(v).unwrap_or_else(|_| { eprintln!("Error: associated data is not valid hex."); @@ -110,13 +130,53 @@ pub(crate) fn aead128_cmd( let ad_opt = if ad_bytes.is_empty() { None } else { Some(ad_bytes.as_slice()) }; if decrypt { - aead128_decrypt_stream(&key, &nonce, ad_opt, output_hex); + aead128_decrypt_stream(&key, nonce.as_ref(), ad_opt, output_hex); } else { - aead128_encrypt_stream(&key, &nonce, ad_opt, output_hex); + aead128_encrypt_stream(&key, nonce.as_ref(), ad_opt, output_hex); } } fn aead128_encrypt_stream( + key: &KeyMaterial<16>, + nonce: Option<&[u8; 16]>, + ad_opt: Option<&[u8]>, + output_hex: bool, +) { + if let Some(nonce) = nonce { + aead128_encrypt_stream_with_explicit_nonce(key, nonce, ad_opt, output_hex); + return; + } + + let (mut cipher, nonce) = as SimpleCipherEncryptor< + 16, + 16, + 16, + >>::do_encrypt_init(key) + .unwrap(); + if let Some(ad) = ad_opt { + cipher.do_update_aad::<16, 16, 16>(ad).unwrap(); + } + + helpers::write_bytes_or_hex(&nonce, output_hex); + + let mut buf = [0u8; 1024]; + loop { + let n = io::stdin().read(&mut buf).expect("Failed to read from stdin"); + if n == 0 { + break; + } + let mut out = [0u8; 1024]; + let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); + helpers::write_bytes_or_hex(&out[..written], output_hex); + } + let (tag, tag_len) = cipher.do_final().unwrap(); + helpers::write_bytes_or_hex(&tag[..tag_len], output_hex); + if output_hex { + println!(); + } +} + +fn aead128_encrypt_stream_with_explicit_nonce( key: &KeyMaterial<16>, nonce: &[u8; 16], ad_opt: Option<&[u8]>, @@ -145,17 +205,31 @@ fn aead128_encrypt_stream( /// the last 16 bytes it has seen as soon as it is known not to be the tag. fn aead128_decrypt_stream( key: &KeyMaterial<16>, - nonce: &[u8; 16], + nonce: Option<&[u8; 16]>, ad_opt: Option<&[u8]>, output_hex: bool, ) { const CHUNK: usize = 1024; + let nonce = match nonce { + Some(nonce) => *nonce, + None => { + let mut nonce = [0u8; 16]; + if let Err(e) = io::stdin().read_exact(&mut nonce) { + if e.kind() == io::ErrorKind::UnexpectedEof { + eprintln!("Error: ciphertext is shorter than the 16-byte nonce."); + exit(-1); + } + panic!("Failed to read from stdin: {e}"); + } + nonce + } + }; let mut cipher = as SimpleCipherDecryptor< 16, 16, 16, - >>::do_decrypt_init(key, nonce) + >>::do_decrypt_init(key, &nonce) .unwrap(); if let Some(ad) = ad_opt { cipher.do_update_aad::<16, 16>(ad).unwrap(); @@ -168,10 +242,10 @@ fn aead128_decrypt_stream( break; } let expect = cipher.update_out_len(n); - let mut out = vec![0u8; expect]; + let mut out = [0u8; CHUNK]; // infallible: `out` is sized exactly to `update_out_len`, the only length // `IncorrectOutputBufferLength` could complain about. - let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); + let written = cipher.do_update_out(&buf[..n], &mut out[..expect]).unwrap(); helpers::write_bytes_or_hex(&out[..written], output_hex); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 2a338579..f3c7a2d4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -386,11 +386,11 @@ enum Subcommands { #[arg(long)] key_file: Option, - /// The 128-bit nonce in hex. Must be unique per encryption under a given key. + /// The 128-bit nonce in hex. Optional hazardous override for deterministic vectors. #[arg(long)] nonce: Option, - /// A file containing the 128-bit nonce in hex or binary. + /// A file containing an optional 128-bit nonce in hex or binary. #[arg(long)] nonce_file: Option, @@ -1246,6 +1246,16 @@ enum Subcommands { } fn main() { + std::thread::Builder::new() + .name("bc-rust-main".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(run) + .expect("failed to start CLI thread") + .join() + .expect("CLI thread panicked"); +} + +fn run() { let cli = Cli::parse(); match &cli.subcommands { diff --git a/cli/tests/ascon_cli_tests.rs b/cli/tests/ascon_cli_tests.rs index 3cf3c6de..f4e7dfda 100644 --- a/cli/tests/ascon_cli_tests.rs +++ b/cli/tests/ascon_cli_tests.rs @@ -3,7 +3,7 @@ //! //! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is //! the command-line contract itself -- KAT-level correctness through the pipe, the `ciphertext || -//! tag` layout, `--key-file`/`--nonce-file` loading, AAD, and exit codes -- none of which is +//! tag` layout, generated nonce prefixing, `--key-file`/`--nonce-file` loading, AAD, and exit codes -- none of which is //! reachable from the library API, which `crypto/ascon/tests/*.rs` already covers directly. //! //! The KAT values below are taken from the embedded vectors already pinned in @@ -23,6 +23,8 @@ const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust"); /// The NIST LWC AEAD KAT convention uses key == nonce for the embedded vectors (see /// `crypto/ascon/tests/aead128_tests.rs`'s `aead128_embedded_kat`). const KEY_HEX: &str = "000102030405060708090a0b0c0d0e0f"; +const NONCE_LEN: usize = 16; +const TAG_LEN: usize = 16; /// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output. /// @@ -182,15 +184,18 @@ fn ascon_aead128_matches_the_embedded_kat_for_an_empty_message() { } /// Encrypt then `--decrypt` round-trips a multi-KB payload, byte for byte, and the ciphertext is -/// exactly the plaintext plus the 16-byte tag. +/// exactly the generated nonce plus the plaintext plus the 16-byte tag. #[test] fn ascon_aead128_encrypt_then_decrypt_round_trips() { let plaintext = pseudo_random(4096, 0xC0FFEE); - let ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); - assert_eq!(ciphertext.len(), plaintext.len() + 16, "ciphertext is plaintext plus the tag"); + let ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX], &plaintext); + assert_eq!( + ciphertext.len(), + plaintext.len() + NONCE_LEN + TAG_LEN, + "ciphertext is nonce plus plaintext plus the tag" + ); - let recovered = - run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + let recovered = run_ok(&["ascon-aead128", "--key", KEY_HEX, "--decrypt"], &ciphertext); assert_eq!(recovered, plaintext); } @@ -198,14 +203,9 @@ fn ascon_aead128_encrypt_then_decrypt_round_trips() { #[test] fn ascon_aead128_associated_data_round_trips() { let plaintext = pseudo_random(256, 7); - let ciphertext = run_ok( - &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef"], - &plaintext, - ); - let recovered = run_ok( - &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef", "--decrypt"], - &ciphertext, - ); + let ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX, "--ad", "deadbeef"], &plaintext); + let recovered = + run_ok(&["ascon-aead128", "--key", KEY_HEX, "--ad", "deadbeef", "--decrypt"], &ciphertext); assert_eq!(recovered, plaintext); } @@ -214,14 +214,9 @@ fn ascon_aead128_associated_data_round_trips() { #[test] fn ascon_aead128_wrong_associated_data_is_rejected() { let plaintext = pseudo_random(64, 11); - let ciphertext = run_ok( - &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "deadbeef"], - &plaintext, - ); - let stderr = run_err( - &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--ad", "cafebabe", "--decrypt"], - &ciphertext, - ); + let ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX, "--ad", "deadbeef"], &plaintext); + let stderr = + run_err(&["ascon-aead128", "--key", KEY_HEX, "--ad", "cafebabe", "--decrypt"], &ciphertext); assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); } @@ -231,12 +226,10 @@ fn ascon_aead128_wrong_associated_data_is_rejected() { #[test] fn ascon_aead128_a_flipped_ciphertext_byte_is_rejected() { let plaintext = pseudo_random(64, 1); - let mut ciphertext = - run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); - ciphertext[0] ^= 0x01; + let mut ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX], &plaintext); + ciphertext[NONCE_LEN] ^= 0x01; - let stderr = - run_err(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + let stderr = run_err(&["ascon-aead128", "--key", KEY_HEX, "--decrypt"], &ciphertext); assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); } @@ -244,20 +237,32 @@ fn ascon_aead128_a_flipped_ciphertext_byte_is_rejected() { #[test] fn ascon_aead128_a_flipped_tag_byte_is_rejected() { let plaintext = pseudo_random(64, 2); - let mut ciphertext = - run_ok(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX], &plaintext); + let mut ciphertext = run_ok(&["ascon-aead128", "--key", KEY_HEX], &plaintext); let last = ciphertext.len() - 1; ciphertext[last] ^= 0x01; - let stderr = - run_err(&["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], &ciphertext); + let stderr = run_err(&["ascon-aead128", "--key", KEY_HEX, "--decrypt"], &ciphertext); assert!(stderr.contains("authentication failed"), "stderr: {stderr}"); } -/// Decrypt input shorter than the 16-byte tag is rejected before any tag check is attempted, -/// including the empty-input case. +/// Decrypt input shorter than the generated 16-byte nonce is rejected before any tag check is +/// attempted, including the empty-input case. +#[test] +fn ascon_aead128_decrypt_input_shorter_than_the_nonce_is_rejected() { + for len in [0usize, 1, 15] { + let stderr = run_err( + &["ascon-aead128", "--key", KEY_HEX, "--decrypt"], + &pseudo_random(len, len as u32 + 1), + ); + assert!( + stderr.contains("shorter than the 16-byte nonce"), + "len {len}: stderr should explain the missing nonce: {stderr}" + ); + } +} + #[test] -fn ascon_aead128_decrypt_input_shorter_than_the_tag_is_rejected() { +fn ascon_aead128_explicit_nonce_decrypt_input_shorter_than_the_tag_is_rejected() { for len in [0usize, 1, 15] { let stderr = run_err( &["ascon-aead128", "--key", KEY_HEX, "--nonce", KEY_HEX, "--decrypt"], @@ -270,6 +275,17 @@ fn ascon_aead128_decrypt_input_shorter_than_the_tag_is_rejected() { } } +#[test] +fn ascon_aead128_each_invocation_uses_a_fresh_nonce() { + let plaintext = pseudo_random(32, 19); + let a = run_ok(&["ascon-aead128", "--key", KEY_HEX], &plaintext); + let b = run_ok(&["ascon-aead128", "--key", KEY_HEX], &plaintext); + + assert_eq!(a.len(), plaintext.len() + NONCE_LEN + TAG_LEN); + assert_eq!(b.len(), plaintext.len() + NONCE_LEN + TAG_LEN); + assert_ne!(&a[..NONCE_LEN], &b[..NONCE_LEN], "the CLI reused a nonce"); +} + /// `--key-file`/`--nonce-file` accept binary content, not just hex, the same as the AES commands' /// `--key-file` (see `key_file_accepts_hex_and_binary` in `aes_ctr_cli_tests.rs`). #[test] diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index b8be0622..bf37735c 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -50,25 +50,34 @@ //! assert_eq!(&pt, plaintext); //! ``` //! -//! Authenticated encryption (streaming, in place): +//! Authenticated encryption (streaming, detached tag): //! ``` -//! use bouncycastle_ascon::ascon_aead128::AsconAead128; +//! use bouncycastle_ascon::ascon_aead128::{AsconAead128Decryptor, AsconAead128Encryptor}; //! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; //! //! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); -//! let nonce = [1u8; 16]; -//! -//! let mut buf = *b"secret message!!"; // transformed in place -//! let mut enc = AsconAead128::new(&key, &nonce, Some(b"associated data"), true).unwrap(); -//! enc.do_encrypt_update(&mut buf); // now ciphertext -//! let tag = enc.do_encrypt_final(); //! -//! let mut dec = AsconAead128::new(&key, &nonce, Some(b"associated data"), false).unwrap(); -//! dec.do_decrypt_update(&mut buf); // now plaintext again, but not yet authenticated -//! dec.do_decrypt_final(&tag).unwrap(); // now authenticated -//! assert_eq!(&buf, b"secret message!!"); +//! let plaintext = b"secret message!!"; +//! let (mut enc, nonce) = AsconAead128Encryptor::do_encrypt_init(&key).unwrap(); +//! enc.do_update_aad(b"associated data").unwrap(); +//! let mut ciphertext = [0u8; 16]; +//! enc.do_update_out(plaintext, &mut ciphertext).unwrap(); +//! let mut final_buf = [0u8; 0]; +//! let (_, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); +//! +//! let mut dec = AsconAead128Decryptor::do_decrypt_init(&key, &nonce).unwrap(); +//! dec.do_update_aad(b"associated data").unwrap(); +//! let mut recovered = [0u8; 16]; +//! dec.do_update_out(&ciphertext, &mut recovered).unwrap(); +//! dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); // now authenticated +//! assert_eq!(&recovered, plaintext); //! ``` //! +//! For the inline `ciphertext || tag` layout, wrap the pair in +//! [`bouncycastle_core::tagged_aead::TaggedEncryptor`] / +//! [`bouncycastle_core::tagged_aead::TaggedDecryptor`]. +//! //! Extendable output: //! ``` //! use bouncycastle_ascon::ascon_xof128::AsconXof128; @@ -109,10 +118,11 @@ //! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`] and the //! `AEADCipher` trait impl) zeroize their output buffer before returning that //! error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / -//! [`ascon_aead128::AsconAead128::do_decrypt_final`]) does not: plaintext bytes are necessarily -//! written to the caller's buffer *before* the tag can be checked, so an application streaming a -//! large plaintext must have a way to cancel the operation or transaction if finalization returns -//! an error. +//! [`ascon_aead128::AsconAead128::do_decrypt_final`] or +//! [`ascon_aead128::AsconAead128Decryptor::do_decrypt_final`]) does not: plaintext bytes are +//! necessarily written to the caller's buffer *before* the tag can be checked, so an application +//! streaming a large plaintext must have a way to cancel the operation or transaction if +//! finalization returns an error. // `bouncycastle-core` still uses `Vec` internally (see the TODO at the top of // crypto/core/src/lib.rs), which blocks this crate from being `#![no_std]` as long as it depends diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs index d9b06635..b13f8c11 100644 --- a/crypto/ascon/tests/aead128_tests.rs +++ b/crypto/ascon/tests/aead128_tests.rs @@ -605,6 +605,11 @@ fn aead128_encryptor_decryptor_trait_framework() { .test_encryptor_decryptor::<16, 16, 16, 0, AsconAead128Encryptor, AsconAead128Decryptor>(); } +#[test] +fn aead_framework_buffering_toy() { + TestFrameworkAEADCipher::new().test_buffering_toy(); +} + /// The inline-tag adapter ([`TaggedEncryptor`]/[`TaggedDecryptor`]) over the same /// [`AsconAead128Encryptor`]/[`AsconAead128Decryptor`] pair must pass the unrelated /// [`SimpleCipherEncryptor`]/[`SimpleCipherDecryptor`] conformance suite -- proof that adapting an diff --git a/crypto/ascon/tests/cxof128_tests.rs b/crypto/ascon/tests/cxof128_tests.rs index bf3ee43b..d641bc5c 100644 --- a/crypto/ascon/tests/cxof128_tests.rs +++ b/crypto/ascon/tests/cxof128_tests.rs @@ -137,6 +137,15 @@ fn cxof128_prefix_property_and_streaming() { } } +#[test] +fn cxof128_hash_view_metadata() { + let x = AsconCXof128::new(); + + assert_eq!(x.block_bitlen(), 64); + assert_eq!(x.output_len(), 32); + assert_eq!(x.hash(b"").len(), 32); +} + #[test] fn cxof128_byte_at_a_time_matches_one_shot() { let msg = pattern(40); @@ -164,29 +173,43 @@ fn cxof128_unsupported_partial_input_returns_err() { assert!(AsconCXof128::new().do_final_partial_bits(0x80, 0).is_ok()); - // Real partial-byte input is deliberately unsupported by Ascon-CXOF128. - assert!(matches!( - AsconCXof128::new().into_squeezer_partial_bits(0xA0, 3), - Err(HashError::InvalidInput(_)) - )); + for num_bits in [3usize, 7] { + assert!(matches!( + AsconCXof128::new().into_squeezer_partial_bits(0xA0, num_bits), + Err(HashError::InvalidInput(_)) + )); - assert!(matches!( - AsconCXof128::new().do_final_partial_bits(0xA0, 3), - Err(HashError::InvalidInput(_)) - )); + assert!(matches!( + AsconCXof128::new().do_final_partial_bits(0xA0, num_bits), + Err(HashError::InvalidInput(_)) + )); - let mut out = [0u8; 32]; + let mut out = [0u8; 32]; - assert!(matches!( - AsconCXof128::new().do_final_partial_bits_out(0xA0, 3, &mut out), - Err(HashError::InvalidInput(_)) - )); + assert!(matches!( + AsconCXof128::new().do_final_partial_bits_out(0xA0, num_bits, &mut out), + Err(HashError::InvalidInput(_)) + )); + } - // More than seven bits is not a partial byte at all. - assert!(matches!( - AsconCXof128::new().into_squeezer_partial_bits(0xFF, 8), - Err(HashError::InvalidLength(_)) - )); + for num_bits in [8usize, 9] { + assert!(matches!( + AsconCXof128::new().into_squeezer_partial_bits(0xFF, num_bits), + Err(HashError::InvalidLength(_)) + )); + + assert!(matches!( + AsconCXof128::new().do_final_partial_bits(0xFF, num_bits), + Err(HashError::InvalidLength(_)) + )); + + let mut out = [0u8; 32]; + + assert!(matches!( + AsconCXof128::new().do_final_partial_bits_out(0xFF, num_bits, &mut out), + Err(HashError::InvalidLength(_)) + )); + } } #[test] diff --git a/crypto/ascon/tests/xof128_tests.rs b/crypto/ascon/tests/xof128_tests.rs index acc2e768..b0c2c74d 100644 --- a/crypto/ascon/tests/xof128_tests.rs +++ b/crypto/ascon/tests/xof128_tests.rs @@ -105,6 +105,15 @@ fn xof128_prefix_property_and_streaming() { } } +#[test] +fn xof128_hash_view_metadata() { + let x = AsconXof128::new(); + + assert_eq!(x.block_bitlen(), 64); + assert_eq!(x.output_len(), 32); + assert_eq!(x.hash(b"").len(), 32); +} + #[test] fn xof128_byte_at_a_time_matches_one_shot() { let msg = pattern(40); @@ -132,29 +141,43 @@ fn xof128_unsupported_partial_input_returns_err() { assert!(AsconXof128::new().do_final_partial_bits(0x80, 0).is_ok()); - // Genuine partial-byte input is intentionally unsupported. - assert!(matches!( - AsconXof128::new().into_squeezer_partial_bits(0xA0, 3), - Err(HashError::InvalidInput(_)) - )); + for num_bits in [3usize, 7] { + assert!(matches!( + AsconXof128::new().into_squeezer_partial_bits(0xA0, num_bits), + Err(HashError::InvalidInput(_)) + )); - assert!(matches!( - AsconXof128::new().do_final_partial_bits(0xA0, 3), - Err(HashError::InvalidInput(_)) - )); + assert!(matches!( + AsconXof128::new().do_final_partial_bits(0xA0, num_bits), + Err(HashError::InvalidInput(_)) + )); - let mut out = [0u8; 32]; + let mut out = [0u8; 32]; - assert!(matches!( - AsconXof128::new().do_final_partial_bits_out(0xA0, 3, &mut out), - Err(HashError::InvalidInput(_)) - )); + assert!(matches!( + AsconXof128::new().do_final_partial_bits_out(0xA0, num_bits, &mut out), + Err(HashError::InvalidInput(_)) + )); + } - // Eight bits is not a partial byte. - assert!(matches!( - AsconXof128::new().into_squeezer_partial_bits(0xFF, 8), - Err(HashError::InvalidLength(_)) - )); + for num_bits in [8usize, 9] { + assert!(matches!( + AsconXof128::new().into_squeezer_partial_bits(0xFF, num_bits), + Err(HashError::InvalidLength(_)) + )); + + assert!(matches!( + AsconXof128::new().do_final_partial_bits(0xFF, num_bits), + Err(HashError::InvalidLength(_)) + )); + + let mut out = [0u8; 32]; + + assert!(matches!( + AsconXof128::new().do_final_partial_bits_out(0xFF, num_bits, &mut out), + Err(HashError::InvalidLength(_)) + )); + } } #[test] diff --git a/crypto/core/src/tagged_aead.rs b/crypto/core/src/tagged_aead.rs index 9874e172..1d49aad1 100644 --- a/crypto/core/src/tagged_aead.rs +++ b/crypto/core/src/tagged_aead.rs @@ -102,7 +102,11 @@ where fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { let mut nothing = [0u8; 0]; let (flushed, tag) = self.0.do_encrypt_final(&mut nothing)?; - debug_assert_eq!(flushed, 0, "FINAL_LEN = 0 on the AEADCipherEncryptor bound"); + if flushed != 0 { + return Err(SymmetricCipherError::GenericError( + "AEAD with FINAL_LEN = 0 flushed data at finalization", + )); + } Ok((tag, TAG_LEN)) } diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 4fc5219b..6eacb97c 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -187,7 +187,7 @@ pub trait AEADCipherDecryptor< /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if /// given `input_len` more bytes of ciphertext. Depends on what is already buffered; identically - /// `0` for a cipher that never holds anything back, such as Ascon-AEAD128. + /// `input_len` for a cipher that never holds anything back, such as Ascon-AEAD128. fn update_out_len(&self, input_len: usize) -> usize; /// Streaming: consumes `ciphertext`, writing every plaintext byte that can be released so far @@ -402,7 +402,7 @@ pub trait AEADCipherEncryptor< /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if /// given `input_len` more bytes of plaintext. Depends on what is already buffered; identically - /// `0` for a cipher that never holds anything back, such as Ascon-AEAD128. + /// `input_len` for a cipher that never holds anything back, such as Ascon-AEAD128. fn update_out_len(&self, input_len: usize) -> usize; /// Streaming: consumes `plaintext`, writing every ciphertext byte that can be produced so far @@ -463,7 +463,8 @@ pub trait AEADCipherEncryptor< let written = enc.do_update_out(plaintext, ciphertext)?; let mut final_buf = [0u8; FINAL_LEN]; let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; - // `encrypt_out_len` bounds `written + final_len`, so this fits in `ciphertext[..needed]`. + // Implementors with FINAL_LEN > 0 must override `encrypt_out_len` so this fits in + // `ciphertext[..needed]`. ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok((nonce, written + final_len, tag)) } From 6d849a183940d114d1e369b7d9c7919d9ffe6961 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 18:36:35 +1000 Subject: [PATCH 06/16] cli, ascon: document the generated-nonce stream layout and the 8 MiB CLI thread, and fix a broken intra-doc link (#119) Review follow-ups on the head of #120; no behaviour changes. - cli/src/main.rs, cli/src/ascon_cmd.rs: the ascon-aead128 command's help and module docs still described the pre-nonce-prefix format ("output = ciphertext||tag") after the command started generating a nonce and writing it as the first 16 bytes of the stream. They now spell the convention out in both directions, the way aes128-ctr's help does for its own nonce, and say what --nonce/--nonce-file turn off -- the part a user gets wrong, since feeding a prefixed ciphertext to "--decrypt --nonce ..." decrypts garbage and only then fails the tag check. The two encrypt paths each gain a line saying which API they drive and why the explicit-nonce one cannot use the AEADCipherEncryptor pair (do_encrypt_init generates the nonce by construction). - cli/src/main.rs: fn main's 8 MiB thread gains a comment for the constraint it exists for. It is load-bearing: with it removed and `ulimit -s 1024`, every subcommand -- sha3-256 as much as ascon-aead128 -- overflows during argument parsing in a debug build, before any algorithm runs. - crypto/ascon/src/lib.rs: [`ascon_aead128::AsconAead128Decryptor::do_decrypt_final`] does not resolve, because do_decrypt_final is an AEADCipherDecryptor method rather than an inherent one, so `cargo doc` warned and published a dead link. Points at the trait method instead. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- cli/src/ascon_cmd.rs | 20 +++++++++++++++++--- cli/src/main.rs | 29 ++++++++++++++++++++++++----- crypto/ascon/src/lib.rs | 8 ++++---- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/cli/src/ascon_cmd.rs b/cli/src/ascon_cmd.rs index 64bbf3fd..3382988b 100644 --- a/cli/src/ascon_cmd.rs +++ b/cli/src/ascon_cmd.rs @@ -100,9 +100,15 @@ pub(crate) fn cxof128_cmd(customization: &Option, output_len: usize, out helpers::stream_xof(x, output_len, output_hex); } -/// Ascon-AEAD128 of stdin. Encrypts (stdin = plaintext, output = ciphertext||tag) or, with -/// `decrypt`, decrypts (stdin = ciphertext||tag, output = plaintext). Decryption exits with a -/// non-zero status if the authentication tag does not verify. +/// Ascon-AEAD128 of stdin. Encrypts (stdin = plaintext, output = nonce||ciphertext||tag) or, with +/// `decrypt`, decrypts (stdin = nonce||ciphertext||tag, output = plaintext). Decryption exits with +/// a non-zero status if the authentication tag does not verify. +/// +/// The 16-byte nonce is generated by the library and travels at the head of the stream, the same +/// convention `block_mode_cmd`/`stream_mode_cmd` use for their IV, so an encrypt and a decrypt +/// compose in a pipeline with nothing but the key passed between them. A caller-supplied `nonce` +/// overrides that and is kept out of the stream in both directions; it is there for known-answer +/// vectors, and repeating one under a given key breaks Ascon-AEAD128 outright. /// /// Both directions stream stdin in fixed-size chunks (no full-buffer slurp). Encryption emits /// ciphertext eagerly, before the tag is known; note that in the decryption direction, plaintext @@ -136,6 +142,10 @@ pub(crate) fn aead128_cmd( } } +/// Generated-nonce encryption: drives [`TaggedEncryptor`] over [`AsconAead128Encryptor`], writing +/// the nonce it returns ahead of the `ciphertext || tag` the adapter produces. With an explicit +/// nonce there is nothing to write, so that case goes to +/// [`aead128_encrypt_stream_with_explicit_nonce`] instead. fn aead128_encrypt_stream( key: &KeyMaterial<16>, nonce: Option<&[u8; 16]>, @@ -176,6 +186,10 @@ fn aead128_encrypt_stream( } } +/// Encryption under a caller-supplied nonce, which nothing is written to the stream for. This +/// drives the inherent [`AsconAead128`] API rather than the `AEADCipherEncryptor` pair because the +/// pair generates its own nonce by construction -- `do_encrypt_init` owns that choice, which is +/// the point of the trait -- and has no caller-supplied-nonce constructor to call here. fn aead128_encrypt_stream_with_explicit_nonce( key: &KeyMaterial<16>, nonce: &[u8; 16], diff --git a/cli/src/main.rs b/cli/src/main.rs index f3c7a2d4..1a0fe0fd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -367,11 +367,23 @@ enum Subcommands { x: bool, }, - /// Ascon-AEAD128 authenticated encryption/decryption of the content provided on stdin. - /// Encrypts by default (stdin = plaintext, output = ciphertext||tag); with --decrypt the - /// reverse. Decryption fails with a non-zero exit status if the tag does not verify. + /// Ascon-AEAD128 authenticated encryption/decryption of the content provided on stdin + /// (NIST SP 800-232). + /// + /// On encrypt, a fresh nonce is generated and written as the FIRST 16 BYTES of the output, + /// followed by the ciphertext and then the 16-byte tag; on --decrypt the nonce is read back + /// from the first 16 bytes of the input, so the two compose directly in a pipeline. This is + /// the same convention the AES commands use for their IV. Decryption fails with a non-zero + /// exit status if the tag does not verify. + /// + /// --nonce/--nonce-file override that: the nonce is then neither written on encrypt nor read + /// on decrypt, and the stream is exactly ciphertext||tag in both directions. That override + /// exists for reproducing known-answer vectors; repeating a nonce under one key destroys both + /// the confidentiality and the authenticity of Ascon-AEAD128. + /// /// Note: in production uses, secrets should not be passed on the command-line because they get /// logged in shell history. Use the file-based input instead. + /// /// Security note: decryption streams its output, so plaintext bytes are written to stdout /// before the authentication tag (the last 16 bytes of input) can be checked. Do not treat /// the output as authentic until this command exits with status 0; a non-zero exit means the @@ -386,11 +398,12 @@ enum Subcommands { #[arg(long)] key_file: Option, - /// The 128-bit nonce in hex. Optional hazardous override for deterministic vectors. + /// The 128-bit nonce in hex. Hazardous override: supplying it keeps the nonce out of the + /// stream (see above), and reusing one under a given key breaks the cipher. #[arg(long)] nonce: Option, - /// A file containing an optional 128-bit nonce in hex or binary. + /// A file containing a 128-bit nonce in hex or binary; the same hazardous override. #[arg(long)] nonce_file: Option, @@ -1245,6 +1258,12 @@ enum Subcommands { }, } +// The CLI body runs on a spawned thread with an explicit 8 MiB stack rather than directly on the +// process's main thread, whose size this program does not control: on Linux it is `ulimit -s` +// (8 MiB by default), and it can be a good deal smaller elsewhere or under a tightened limit. With +// a 1 MiB main stack a debug build overflows during argument parsing -- in every subcommand, before +// any algorithm runs -- so this is a property of the command tree, not of one algorithm's state. +// 8 MiB is the usual Linux default; do not lower it without re-checking that case. fn main() { std::thread::Builder::new() .name("bc-rust-main".to_string()) diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index bf37735c..a5d6eff9 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -119,10 +119,10 @@ //! `AEADCipher` trait impl) zeroize their output buffer before returning that //! error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / //! [`ascon_aead128::AsconAead128::do_decrypt_final`] or -//! [`ascon_aead128::AsconAead128Decryptor::do_decrypt_final`]) does not: plaintext bytes are -//! necessarily written to the caller's buffer *before* the tag can be checked, so an application -//! streaming a large plaintext must have a way to cancel the operation or transaction if -//! finalization returns an error. +//! [`bouncycastle_core::traits::AEADCipherDecryptor::do_decrypt_final`]) does not: plaintext +//! bytes are necessarily written to the caller's buffer *before* the tag can be checked, so an +//! application streaming a large plaintext must have a way to cancel the operation or +//! transaction if finalization returns an error. // `bouncycastle-core` still uses `Vec` internally (see the TODO at the top of // crypto/core/src/lib.rs), which blocks this crate from being `#![no_std]` as long as it depends From bbc04e3f051cd4e8674b2582c9b0e3d31afb82d0 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 18:41:07 +1000 Subject: [PATCH 07/16] release notes: the current bouncycastle-ascon mutation figures (#119) The entry carried the pre-remediation run, flagged as such ("20 missed before the XOF/CXOF boundary-test additions"). Re-measured on this head with `cargo mutants -p bouncycastle-ascon --test-package bouncycastle-ascon --jobs 3 --timeout 120`, with bc-test-data reachable from the copied tree and a config whose examine_globs block is removed: 735 mutants, 618 caught, 111 unviable, 6 missed. The six are the known equivalences already commented at their sites -- the sponge absorb/squeeze boundaries and the two disjoint-bit `|` -> `^` in set_state_byte -- so the 14 real survivors that run found in the XOF/CXOF Hash view are dead. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- alpha_0.1.3_release_notes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index a6cbbb50..d8f83f95 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -21,8 +21,9 @@ data back. * Testing covers the ASCON NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus - embedded always-on vectors. Mutation testing for `bouncycastle-ascon` currently reports 735 - mutants, 604 caught, 111 unviable and 20 missed before the XOF/CXOF boundary-test additions. + embedded always-on vectors. Mutation testing for `bouncycastle-ascon` reports 735 mutants, + 618 caught, 111 unviable and 6 missed; the six survivors are the sponge boundary and + `set_state_byte` OR/XOR equivalences documented at their sites. ## Minor features / bug fixes From 2f7c32a8dc02815ca32bdfc33d1709078ba64056 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 18:53:15 +1000 Subject: [PATCH 08/16] core, core-test-framework, ascon: delete the AEADCipher trait, superseded by the AEADCipherEncryptor/AEADCipherDecryptor split (#119) AEADCipher was the single-type AEAD trait this issue exists to split. It had no implementor on the base branch and its conformance suite had nothing to run against; this PR was about to give it its first and only implementor, on AsconAead128, in the same change that introduces the pair meant to replace it. That would have left the library with two parallel AEAD abstractions and Ascon-AEAD128 with four public one-shot encrypt surfaces. Deleted instead: - crypto/core/src/traits.rs: the trait itself (encrypt/encrypt_out/decrypt/decrypt_out, the aead_* pair, do_aead_encrypt_final/do_aead_decrypt_final). The AEADCipherEncryptor doc that contrasted its tag placement with this trait's now just points at tagged_aead. - crypto/core-test-framework/src/symmetric_ciphers.rs: TestFrameworkAEADCipher::test and ::test_plain_one_shots, the suites for it. The struct keeps test_encryptor_decryptor and test_buffering_toy, which exercise the pair. - crypto/ascon/src/ascon_aead128.rs: the impl, and the module-doc sentence that justified the newtype pair by pointing at it. Test coverage is kept where it was about Ascon rather than about the trait: the chunk-boundary sweep and the wrong-tag rejection now drive the inherent do_encrypt_final/do_decrypt_final (they only used the trait for its finalizers), and the undersized-buffer suite is rewritten against the inherent one-shots, whose own length checks -- including the 16-byte-ciphertext and oversized-buffer boundaries that must NOT be rejected -- were previously reached only through the trait. The three tests that were about the deleted code (the std Vec wrappers, the plain view's DecryptionFailed remapping, the AEADCipher framework conformance call) go with it. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- crypto/ascon/src/ascon_aead128.rs | 170 +---------- crypto/ascon/src/lib.rs | 6 +- crypto/ascon/tests/aead128_tests.rs | 171 ++--------- .../src/symmetric_ciphers.rs | 266 +----------------- crypto/core/src/traits.rs | 134 +-------- 5 files changed, 39 insertions(+), 708 deletions(-) diff --git a/crypto/ascon/src/ascon_aead128.rs b/crypto/ascon/src/ascon_aead128.rs index ee34d2cd..b2d628d1 100644 --- a/crypto/ascon/src/ascon_aead128.rs +++ b/crypto/ascon/src/ascon_aead128.rs @@ -17,8 +17,8 @@ //! its own direction and only ever calls that direction's inherent methods, so the wrong-direction //! panics inside [`AsconAead128::do_encrypt_update`] and friends are unreachable through them. See //! their docs for why a thin newtype pair rather than encoding the direction into `AsconAead128` -//! itself: that would need a second, incompatible implementation of the single-type [`AEADCipher`] -//! this module also provides, which needs both directions available on the one type. +//! itself: the inherent API is deliberately one type serving both directions, which is what the +//! in-place streaming and the explicit-nonce one-shots are built on. use core::fmt::{self, Debug, Display, Formatter}; @@ -26,8 +26,7 @@ use bouncycastle_core::errors::{KeyMaterialError, SuspendableError, SymmetricCip use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; use bouncycastle_core::traits::{ - AEADCipher, AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, - SuspendableKeyed, + AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, SuspendableKeyed, }; use bouncycastle_rng::HashDRBG_SHA512; use bouncycastle_utils::ct::ct_eq_bytes; @@ -476,169 +475,6 @@ impl Algorithm for AsconAead128 { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; } -// Ascon-AEAD128 as an `AEADCipher`. `encrypt`/`encrypt_out`/`decrypt`/`decrypt_out` are the -// "basic" (non-AEAD) view: the init data is the 128-bit nonce, and the ciphertext produced by -// these APIs is `Ascon ciphertext || 16-byte tag` (empty AAD). `aead_*` are the full AEAD view -// with associated data and a separate tag. -impl AEADCipher for AsconAead128 { - #[cfg(feature = "std")] - fn encrypt( - key: &KeyMaterial, - plaintext: &[u8], - ) -> Result<([u8; NONCE_LEN], Vec), SymmetricCipherError> { - let mut ciphertext = vec![0u8; plaintext.len() + TAG_LEN]; - let (nonce, written) = Self::encrypt_out(key, plaintext, &mut ciphertext)?; - ciphertext.truncate(written); - Ok((nonce, ciphertext)) - } - - fn encrypt_out( - key: &KeyMaterial, - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError> { - let _ = Self::checked_key(key)?; - let nonce = Self::fresh_nonce()?; - // No associated data for the plain, non-AEAD view; the tag is appended to `ciphertext`. - // `encrypt` itself checks that `ciphertext` is long enough. - let written = Self::encrypt(key, &nonce, None, plaintext, ciphertext)?; - Ok((nonce, written)) - } - - #[cfg(feature = "std")] - fn decrypt( - key: &KeyMaterial, - init_data: [u8; NONCE_LEN], - ciphertext: &[u8], - ) -> Result, SymmetricCipherError> { - if ciphertext.len() < TAG_LEN { - return Err(SymmetricCipherError::GenericError( - "Ascon-AEAD128 ciphertext shorter than tag", - )); - } - let mut plaintext = vec![0u8; ciphertext.len() - TAG_LEN]; - let written = Self::decrypt_out(key, init_data, ciphertext, &mut plaintext)?; - plaintext.truncate(written); - Ok(plaintext) - } - - fn decrypt_out( - key: &KeyMaterial, - init_data: [u8; NONCE_LEN], - ciphertext: &[u8], - plaintext: &mut [u8], - ) -> Result { - let _ = Self::checked_key(key)?; - if ciphertext.len() < TAG_LEN { - return Err(SymmetricCipherError::GenericError( - "Ascon-AEAD128 ciphertext shorter than tag", - )); - } - let pt_len = ciphertext.len() - TAG_LEN; - if plaintext.len() < pt_len { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "Ascon-AEAD128 plaintext buffer too small", - pt_len, - )); - } - // `ciphertext` is `Ascon ciphertext || 16-byte tag`; `decrypt` splits it internally. - // This plain, non-AEAD view has no AAD and so nothing that distinguishes an - // authentication failure from any other decryption failure; report both as - // `DecryptionFailed`, matching the trait's documented "the caller learns only that - // decryption failed". `AEADTagCheckFailed` is reserved for the AEAD view - // (`aead_decrypt`/`aead_decrypt_out`), which is honest about there being a separate tag. - Self::decrypt(key, &init_data, None, ciphertext, plaintext).map_err(|e| match e { - SymmetricCipherError::AEADTagCheckFailed => SymmetricCipherError::DecryptionFailed, - other => other, - }) - } - - #[cfg(feature = "std")] - fn aead_encrypt( - key: &KeyMaterial, - aad: &[u8], - plaintext: &[u8], - ) -> Result<([u8; NONCE_LEN], Vec, [u8; TAG_LEN]), SymmetricCipherError> { - let mut ciphertext = vec![0u8; plaintext.len()]; - let (nonce, written, tag) = Self::aead_encrypt_out(key, aad, plaintext, &mut ciphertext)?; - ciphertext.truncate(written); - Ok((nonce, ciphertext, tag)) - } - - fn aead_encrypt_out( - key: &KeyMaterial, - aad: &[u8], - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { - let _ = Self::checked_key(key)?; - if ciphertext.len() < plaintext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "Ascon-AEAD128 ciphertext buffer too small", - plaintext.len(), - )); - } - let nonce = Self::fresh_nonce()?; - let aad_opt = if aad.is_empty() { None } else { Some(aad) }; - let mut cipher = Self::new(key, &nonce, aad_opt, true)?; - ciphertext[..plaintext.len()].copy_from_slice(plaintext); - cipher.do_encrypt_update(&mut ciphertext[..plaintext.len()]); - let tag = cipher.do_encrypt_final(); - Ok((nonce, plaintext.len(), tag)) - } - - fn do_aead_encrypt_final(self) -> Result<[u8; TAG_LEN], SymmetricCipherError> { - Ok(self.do_encrypt_final()) - } - - #[cfg(feature = "std")] - fn aead_decrypt( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - aad: &[u8], - ciphertext: &[u8], - tag: &[u8; TAG_LEN], - ) -> Result, SymmetricCipherError> { - let mut plaintext = vec![0u8; ciphertext.len()]; - let written = Self::aead_decrypt_out(key, nonce, aad, ciphertext, tag, &mut plaintext)?; - plaintext.truncate(written); - Ok(plaintext) - } - - fn aead_decrypt_out( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - aad: &[u8], - ciphertext: &[u8], - tag: &[u8; TAG_LEN], - plaintext: &mut [u8], - ) -> Result { - let _ = Self::checked_key(key)?; - if plaintext.len() < ciphertext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "Ascon-AEAD128 plaintext buffer too small", - ciphertext.len(), - )); - } - let aad_opt = if aad.is_empty() { None } else { Some(aad) }; - let mut cipher = Self::new(key, nonce, aad_opt, false)?; - plaintext[..ciphertext.len()].copy_from_slice(ciphertext); - cipher.do_decrypt_update(&mut plaintext[..ciphertext.len()]); - match cipher.do_decrypt_final(tag) { - Ok(()) => Ok(ciphertext.len()), - Err(e) => { - // A failed tag check must not leave plaintext in the caller's buffer. - plaintext[..ciphertext.len()].fill(0); - Err(e) - } - } - } - - fn do_aead_decrypt_final(self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError> { - self.do_decrypt_final(tag) - } -} - /// Adapts [`AsconAead128`]'s encrypting direction to [`AEADCipherEncryptor`]; see the module docs /// for why this is a thin wrapper rather than a change to `AsconAead128` itself. pub struct AsconAead128Encryptor(AsconAead128); diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index a5d6eff9..cf3615f4 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -115,9 +115,9 @@ //! caller that needs a partial-byte final block should reach for SHA-3, which supports one. //! - **Decryption tag check failure:** a ciphertext decryption whose finalization returns //! `Err(SymmetricCipherError::AEADTagCheckFailed)` must be treated as tampered, and the entire -//! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`] and the -//! `AEADCipher` trait impl) zeroize their output buffer before returning that -//! error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / +//! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`] and +//! [`bouncycastle_core::traits::AEADCipherDecryptor::decrypt_out`]) zeroize their output buffer +//! before returning that error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / //! [`ascon_aead128::AsconAead128::do_decrypt_final`] or //! [`bouncycastle_core::traits::AEADCipherDecryptor::do_decrypt_final`]) does not: plaintext //! bytes are necessarily written to the caller's buffer *before* the tag can be checked, so an diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs index b13f8c11..15387da1 100644 --- a/crypto/ascon/tests/aead128_tests.rs +++ b/crypto/ascon/tests/aead128_tests.rs @@ -4,8 +4,10 @@ //! repo required). The full sweep lives in `bc_test_data.rs`. //! - Behavioral / contract tests (round-trips, streaming chunk-boundary equivalence, authentication //! failures, determinism), driven through the inherent explicit-nonce API. -//! - The shared `AEADCipher` conformance framework (`core-test-framework`), which exercises the -//! generic `AEADCipher` trait surface with internally-generated nonces. +//! - The shared conformance framework (`core-test-framework`), which exercises the +//! `AEADCipherEncryptor`/`AEADCipherDecryptor` pair and, through `TaggedEncryptor`/ +//! `TaggedDecryptor`, the `SimpleCipherEncryptor`/`SimpleCipherDecryptor` surface, both with +//! internally-generated nonces. use bouncycastle_ascon::ascon_aead128::{ AsconAead128, AsconAead128Decryptor, AsconAead128Encryptor, @@ -243,13 +245,11 @@ fn aead_chunked_aad_matches_one_shot() { } /* -------------------------------------------------------------------------- */ -/* Trait-driven streaming sweep (this is what would have caught F1/F2) */ +/* Streaming chunk sweep (this is what would have caught F1/F2) */ /* -------------------------------------------------------------------------- */ #[test] -fn aead_trait_streaming_sweep() { - use bouncycastle_core::traits::AEADCipher; - +fn aead_streaming_chunk_sweep() { let km = key_material(&KEY); for pt_len in 0..=40 { let pt = pattern(pt_len); @@ -269,7 +269,7 @@ fn aead_trait_streaming_sweep() { e.do_encrypt_update(&mut out[off..end]); off = end; } - let tag = e.do_aead_encrypt_final().unwrap(); + let tag = e.do_encrypt_final(); assert_eq!(out, ct_ref_body, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); assert_eq!(tag, tag_ref, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); @@ -282,7 +282,7 @@ fn aead_trait_streaming_sweep() { off = end; } let tag_arr: [u8; 16] = tag_ref.try_into().unwrap(); - d.do_aead_decrypt_final(&tag_arr).unwrap(); + d.do_decrypt_final(&tag_arr).unwrap(); assert_eq!(back, pt, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); } } @@ -290,9 +290,7 @@ fn aead_trait_streaming_sweep() { } #[test] -fn do_aead_decrypt_final_rejects_wrong_tag() { - use bouncycastle_core::traits::AEADCipher; - +fn do_decrypt_final_rejects_wrong_tag() { let km = key_material(&KEY); let pt = pattern(20); let mut d = AsconAead128::new(&km, &NONCE, None, false).unwrap(); @@ -300,167 +298,63 @@ fn do_aead_decrypt_final_rejects_wrong_tag() { d.do_decrypt_update(&mut buf); let wrong_tag = [0xFFu8; 16]; assert!(matches!( - d.do_aead_decrypt_final(&wrong_tag), + d.do_decrypt_final(&wrong_tag), Err(SymmetricCipherError::AEADTagCheckFailed) )); } /* -------------------------------------------------------------------------- */ -/* std-only Vec-returning trait wrappers */ +/* One-shot buffer-length contract */ /* -------------------------------------------------------------------------- */ -// `TestFrameworkAEADCipher` only exercises the `_out` (buffer-based) -// entry points, so the `#[cfg(feature = "std")]` `Vec`-returning wrappers (`encrypt`, `decrypt`, -// `aead_encrypt`, `aead_decrypt`) are otherwise never called by any test. -#[test] -fn aead128_std_vec_wrappers_round_trip() { - use bouncycastle_core::traits::AEADCipher; - - let km = key_material(&KEY); - let msg = pattern(40); - - let (nonce, ct) = >::encrypt(&km, &msg).unwrap(); - assert_eq!(ct.len(), msg.len() + 16); - let pt = >::decrypt(&km, nonce, &ct).unwrap(); - assert_eq!(pt, msg); - - let (nonce, ct, tag) = - >::aead_encrypt(&km, b"aad", &msg).unwrap(); - assert_eq!(ct.len(), msg.len()); - let pt = >::aead_decrypt(&km, &nonce, b"aad", &ct, &tag) - .unwrap(); - assert_eq!(pt, msg); - - // Tampering must still be rejected through these entry points too. - assert!( - >::aead_decrypt( - &km, &nonce, b"wrong-aad", &ct, &tag - ) - .is_err() - ); -} - -// None of the length checks in the `AEADCipher` `_out` entry points are ever -// triggered by `TestFrameworkAEADCipher` (which always pass a -// generously-sized fixed buffer), nor by the inherent one-shot `encrypt`/`decrypt` tests above -// (which always size their own buffer correctly). Exercise every one directly. +// The length checks in the inherent one-shots are never triggered by the tests above, which all +// size their own buffers correctly, so exercise each one directly -- including the two boundary +// cases that must NOT be rejected. #[test] fn aead128_undersized_buffers_are_rejected() { - use bouncycastle_core::traits::AEADCipher; - let km = key_material(&KEY); let msg = pattern(40); - // AEADCipher::encrypt_out: ciphertext buffer shorter than plaintext.len() + 16. + // encrypt: output buffer shorter than plaintext.len() + 16. let mut too_small = vec![0u8; msg.len() + 15]; - match >::encrypt_out(&km, &msg, &mut too_small) { + match AsconAead128::encrypt(&km, &NONCE, None, &msg, &mut too_small) { Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { assert_eq!(needed, msg.len() + 16); } other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), } - // AEADCipher::decrypt / decrypt_out: ciphertext shorter than the 16-byte tag. + // decrypt: ciphertext shorter than the 16-byte tag, which is checked before the output buffer. let short = [0u8; 8]; - match >::decrypt(&km, NONCE, &short) { - Err(SymmetricCipherError::GenericError(_)) => {} - other => panic!("expected GenericError, got {other:?}"), - } let mut pt_buf = [0u8; 8]; - match >::decrypt_out(&km, NONCE, &short, &mut pt_buf) { + match AsconAead128::decrypt(&km, &NONCE, None, &short, &mut pt_buf) { Err(SymmetricCipherError::GenericError(_)) => {} other => panic!("expected GenericError, got {other:?}"), } - // AEADCipher::decrypt_out: valid-length ciphertext, but undersized plaintext buffer. + // decrypt: valid-length ciphertext, but an undersized plaintext buffer. let ct = enc_oneshot(&KEY, &NONCE, &[], &msg); let mut too_small_pt = vec![0u8; msg.len() - 1]; - match >::decrypt_out(&km, NONCE, &ct, &mut too_small_pt) - { + match AsconAead128::decrypt(&km, &NONCE, None, &ct, &mut too_small_pt) { Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { assert_eq!(needed, msg.len()); } other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), } - // decrypt / decrypt_out: ciphertext of exactly 16 bytes (an empty plaintext plus the tag) is - // the boundary case and must NOT be rejected as "too short". + // A ciphertext of exactly 16 bytes -- an empty plaintext plus its tag -- is the boundary case + // and must decrypt, not be rejected as shorter than the tag. let empty_ct = enc_oneshot(&KEY, &NONCE, &[], &[]); assert_eq!(empty_ct.len(), 16); - assert_eq!( - >::decrypt(&km, NONCE, &empty_ct).unwrap(), - Vec::::new() - ); let mut empty_pt_buf = [0u8; 0]; - assert_eq!( - >::decrypt_out( - &km, NONCE, &empty_ct, &mut empty_pt_buf - ) - .unwrap(), - 0 - ); - - // decrypt_out: a plaintext buffer *larger* than needed must succeed, not be rejected. + assert_eq!(AsconAead128::decrypt(&km, &NONCE, None, &empty_ct, &mut empty_pt_buf).unwrap(), 0); + + // An output buffer larger than needed must succeed, with only the recovered bytes written. let mut oversized_pt = vec![0xAAu8; msg.len() + 5]; - let n = - >::decrypt_out(&km, NONCE, &ct, &mut oversized_pt) - .unwrap(); + let n = AsconAead128::decrypt(&km, &NONCE, None, &ct, &mut oversized_pt).unwrap(); assert_eq!(n, msg.len()); assert_eq!(&oversized_pt[..n], &msg[..]); - - // AEADCipher::aead_encrypt_out: ciphertext buffer shorter than the plaintext. - let mut too_small = vec![0u8; msg.len() - 1]; - match >::aead_encrypt_out( - &km, b"aad", &msg, &mut too_small, - ) { - Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { - assert_eq!(needed, msg.len()); - } - other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), - } - - // AEADCipher::aead_decrypt_out: plaintext buffer shorter than the ciphertext. - let (nonce, ct, tag) = - >::aead_encrypt(&km, b"aad", &msg).unwrap(); - let mut too_small_pt = vec![0u8; ct.len() - 1]; - match >::aead_decrypt_out( - &km, &nonce, b"aad", &ct, &tag, &mut too_small_pt, - ) { - Err(SymmetricCipherError::IncorrectOutputBufferLength(_, needed)) => { - assert_eq!(needed, ct.len()); - } - other => panic!("expected IncorrectOutputBufferLength, got {other:?}"), - } -} - -// The plain (non-AEAD) view's `decrypt`/`decrypt_out` report an authentication failure as -// `DecryptionFailed`, not `AEADTagCheckFailed` (see the comment on `AsconAead128`'s -// `AEADCipher::decrypt_out` impl): this view has no separate tag to name, and the trait's own doc -// comment says every implementor reports it this way. A mutant deleting that remapping would -// otherwise survive, since nothing else in this file calls the plain view on a tampered -// ciphertext. -#[test] -fn aead128_plain_view_reports_tamper_as_decryption_failed() { - use bouncycastle_core::traits::AEADCipher; - - let km = key_material(&KEY); - let msg = pattern(40); - let ct = enc_oneshot(&KEY, &NONCE, &[], &msg); - - let mut tampered = ct.clone(); - tampered[0] ^= 0x01; - - match >::decrypt(&km, NONCE, &tampered) { - Err(SymmetricCipherError::DecryptionFailed) => {} - other => panic!("expected DecryptionFailed, got {other:?}"), - } - - let mut pt_buf = vec![0u8; msg.len()]; - match >::decrypt_out(&km, NONCE, &tampered, &mut pt_buf) - { - Err(SymmetricCipherError::DecryptionFailed) => {} - other => panic!("expected DecryptionFailed, got {other:?}"), - } + assert_eq!(&oversized_pt[n..], &[0xAAu8; 5]); } /* -------------------------------------------------------------------------- */ @@ -580,18 +474,9 @@ fn do_decrypt_update_on_encryptor_panics() { } /* -------------------------------------------------------------------------- */ -/* AEADCipher trait conformance (shared core-test-framework) */ +/* Trait conformance (shared core-test-framework) */ /* -------------------------------------------------------------------------- */ -#[test] -fn aead128_trait_framework() { - // Exercises the generic AEADCipher<16,16,16> surface: internally - // generated (random, distinct) nonces, key-type / key-strength enforcement, and the AEAD - // tamper-detection contract (modified ciphertext / AAD / tag must fail the tag check, and - // must never leave plaintext in the output buffer). - TestFrameworkAEADCipher::new().test::<16, 16, 16, AsconAead128>(); -} - /// Exercises [`AEADCipherEncryptor`]/[`AEADCipherDecryptor`], the streaming pair /// [`AsconAead128Encryptor`]/[`AsconAead128Decryptor`] adapt [`AsconAead128`] to: `update_out_len` /// correctness, chunking-independence of both AAD and data, the AAD-after-data `StateError`, and diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 1dd8a5ef..407910ff 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,9 +6,9 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, AEADCipherDecryptor, AEADCipherEncryptor, BlockCipherDecryptor, - BlockCipherEncryptor, SecurityStrength, SimpleCipherDecryptor, SimpleCipherEncryptor, - StreamCipherDecryptor, StreamCipherEncryptor, + AEADCipherDecryptor, AEADCipherEncryptor, BlockCipherDecryptor, BlockCipherEncryptor, + SecurityStrength, SimpleCipherDecryptor, SimpleCipherEncryptor, StreamCipherDecryptor, + StreamCipherEncryptor, }; /// Instance of the test framework. @@ -460,266 +460,6 @@ impl TestFrameworkAEADCipher { Self {} } - /// Tests the plain one-shots -- [`AEADCipher::encrypt_out`] and - /// [`AEADCipher::decrypt_out`], which take no additional authenticated data. - /// - /// These four methods were the former `SymmetricCipher` trait, and this was its suite; they now - /// belong to `AEADCipher`, so the suite comes with them. Called by - /// [`test`](Self::test), so an implementor gets it without asking, and public so it can be run - /// on its own. - pub fn test_plain_one_shots< - const KEY_LEN: usize, - const NONCE_LEN: usize, - const TAG_LEN: usize, - C: AEADCipher, - >( - &self, - ) { - let msg = b"The quick brown fox jumps over the lazy dog"; - - let key = KeyMaterial::::from_bytes_as_type( - &DUMMY_SEED[..KEY_LEN], - KeyType::SymmetricCipherKey, - ) - .unwrap(); - - // one-shot API - let mut ct = [0u8; 1024]; - let (iv, ct_bytes_written) = C::encrypt_out(&key, msg, &mut ct).unwrap(); - assert_ne!(ct_bytes_written, 0); - - let mut pt = [0u8; 1024]; - let pt_bytes_written = C::decrypt_out(&key, iv, &ct[..ct_bytes_written], &mut pt).unwrap(); - assert_ne!(pt_bytes_written, 0); - assert_eq!(msg, &pt[..pt_bytes_written]); - - // todo -- add tests for encrypt() / decrypt() wrapped in a #[cfg(std)] - - // messing with the ciphertext does not give back the same plaintext (or failing to decrypt is also ok) - ct[17] ^= 0xFF; - match C::decrypt_out(&key, iv, &ct[..ct_bytes_written], &mut pt) { - Ok(bytes_written) => { - // so it decrypted something, but it had better not match the original plaintext - assert_eq!(bytes_written, pt_bytes_written); - assert_ne!(&pt[..bytes_written], msg); - } - Err(SymmetricCipherError::DecryptionFailed) => { /* also ok */ } - _ => panic!("Unexpected error"), - }; - - // error case: KeyMaterial of wrong type - let mac_key = - KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) - .unwrap(); - match C::encrypt_out(&mac_key, msg, &mut ct) { - Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } - _ => panic!("Unexpected error"), - }; - - // error case: security strengths too weak and too strong - let mut key = KeyMaterial::::from_bytes_as_type( - &DUMMY_SEED[..KEY_LEN], - KeyType::SymmetricCipherKey, - ) - .unwrap(); - let security_strengths = [ - SecurityStrength::None, - SecurityStrength::_112bit, - SecurityStrength::_128bit, - SecurityStrength::_192bit, - SecurityStrength::_256bit, - ]; - for ss in security_strengths.iter() { - // `set_security_strength` enforces its key-length guard even inside a - // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a - // strength above `from_bytes(KEY_LEN)` -- so skip the strengths this key cannot carry - // rather than unwrapping an error. (A 16-byte key can reach 128-bit and no higher.) - // Do NOT "fix" this by relaxing that guard in `KeyMaterial`: core's - // `test_hazardous_ops_error_handling` requires it to stay enforced. - if ss > &SecurityStrength::from_bytes(KEY_LEN) { - continue; - } - - // Tag the key at an arbitrary strength for the purpose of this test. - do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); - - match C::encrypt_out(&key, msg, &mut ct) { - Ok(_) => { - if ss >= &C::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should have been a strong enough key"); - } - } - Err(SymmetricCipherError::KeyMaterialError(_)) => { - if ss < &C::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should not have accepted a key weaker than algorithm"); - } - } - _ => panic!("Unexpected error"), - }; - } - } - - /// Test all the members of trait AEADCipher against the given input-output pair. - /// This gives good baseline test coverage, but is not exhaustive. - pub fn test< - const KEY_LEN: usize, - const NONCE_LEN: usize, - const TAG_LEN: usize, - C: AEADCipher, - >( - &self, - ) { - // The plain one-shots this trait absorbed from the former `SymmetricCipher`. - self.test_plain_one_shots::(); - - let msg = b"The quick brown fox jumps over the lazy dog"; - let aad = b"some associated data"; - - let key = KeyMaterial::::from_bytes_as_type( - &DUMMY_SEED[..KEY_LEN], - KeyType::SymmetricCipherKey, - ) - .unwrap(); - - // one-shot API - let mut ct = [0u8; 1024]; - let (nonce, ct_bytes_written, tag) = C::aead_encrypt_out(&key, aad, msg, &mut ct).unwrap(); - if nonce.len() != 0 { - assert_ne!(nonce, [0u8; NONCE_LEN]); - } - assert_ne!(ct_bytes_written, 0); - assert_ne!(tag, [0u8; TAG_LEN]); - - let mut pt = [0u8; 1024]; - let pt_bytes_written = - C::aead_decrypt_out(&key, &nonce, aad, &ct[..ct_bytes_written], &tag, &mut pt).unwrap(); - assert_ne!(pt_bytes_written, 0); - assert_eq!(msg, &pt[..pt_bytes_written]); - - // todo -- add tests for aead_encrypt() / aead_decrypt() wrapped in a #[cfg(std)] - - // Modifying the ciphertext MUST cause an AEAD failure: unlike an unauthenticated cipher, - // a conformant AEAD must never return plaintext for a ciphertext that fails its tag check. - ct[17] ^= 0xFF; - pt[..ct_bytes_written].fill(0xAA); - match C::aead_decrypt_out(&key, &nonce, aad, &ct[..ct_bytes_written], &tag, &mut pt) { - Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } - Err(SymmetricCipherError::DecryptionFailed) => { /* also acceptable */ } - _ => panic!("Modified ciphertext must fail the AEAD tag check"), - }; - assert!( - pt[..ct_bytes_written].iter().all(|&b| b == 0), - "AEAD must not leave plaintext in the output buffer after a failed tag check" - ); - // restore the ciphertext so the AAD- and tag-tamper checks below each test one variable - ct[17] ^= 0xFF; - - // messing with the aad causes the aead_decrypt to fail - pt[..ct_bytes_written].fill(0xAA); - match C::aead_decrypt_out( - &key, - &nonce, - b"not the right associated data", - &ct[..ct_bytes_written], - &tag, - &mut pt, - ) { - Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } - _ => panic!("Expected TagCheckFailed error"), - }; - assert!( - pt[..ct_bytes_written].iter().all(|&b| b == 0), - "AEAD must not leave plaintext in the output buffer after a failed tag check" - ); - - // messing with the tag causes the aead_decrypt to fail - pt[..ct_bytes_written].fill(0xAA); - match C::aead_decrypt_out( - &key, - &nonce, - aad, - &ct[..ct_bytes_written], - &[3u8; TAG_LEN], - &mut pt, - ) { - Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } - _ => panic!("Expected TagCheckFailed error"), - }; - assert!( - pt[..ct_bytes_written].iter().all(|&b| b == 0), - "AEAD must not leave plaintext in the output buffer after a failed tag check" - ); - - // multiple invocations give different nonces - let (nonce1, _ct_bytes_written, _tag) = - C::aead_encrypt_out(&key, aad, msg, &mut ct).unwrap(); - let (nonce2, _ct_bytes_written, _tag) = - C::aead_encrypt_out(&key, aad, msg, &mut ct).unwrap(); - assert_ne!(nonce1, nonce2); - - // error case: KeyMaterial of wrong type - let mac_key = - KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) - .unwrap(); - match C::aead_encrypt_out(&mac_key, aad, msg, &mut ct) { - Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } - _ => panic!("Unexpected error"), - }; - - // error case: security strengths too weak and too strong - let mut key = KeyMaterial::::from_bytes_as_type( - &DUMMY_SEED[..KEY_LEN], - KeyType::SymmetricCipherKey, - ) - .unwrap(); - let security_strengths = [ - SecurityStrength::None, - SecurityStrength::_112bit, - SecurityStrength::_128bit, - SecurityStrength::_192bit, - SecurityStrength::_256bit, - ]; - let mut strengths_tested = 0; - for ss in security_strengths.iter() { - // `set_security_strength` enforces its key-length guard even inside a - // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a - // strength above `from_bytes(KEY_LEN)` -- so skip the strengths this key cannot carry - // rather than unwrapping an error. (A 16-byte key can reach 128-bit and no higher.) - // Do NOT "fix" this by relaxing that guard in `KeyMaterial`: core's - // `test_hazardous_ops_error_handling` requires it to stay enforced. - if ss > &SecurityStrength::from_bytes(KEY_LEN) { - continue; - } - - // Tag the key at an arbitrary strength for the purpose of this test. - do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); - strengths_tested += 1; - - // The key-strength requirement must be enforced both by the AEAD one-shot and by the - // plain one (encrypt_out), so exercise both. - let check_strength = |result: Result<(), SymmetricCipherError>| match result { - Ok(_) => { - if ss >= &C::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should have been a strong enough key"); - } - } - Err(SymmetricCipherError::KeyMaterialError(_)) => { - if ss < &C::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should not have accepted a key weaker than algorithm"); - } - } - _ => panic!("Unexpected error"), - }; - check_strength(C::aead_encrypt_out(&key, aad, msg, &mut ct).map(|_| ())); - check_strength(C::encrypt_out(&key, msg, &mut ct).map(|_| ())); - } - assert!(strengths_tested > 0, "strength sweep must not be vacuous"); - } - /// Exercises the [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] streaming contract for a /// paired implementor. The counterpart of [`TestFrameworkBlockCipher::test`] for an /// authenticated cipher. diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 3df012e4..d54cdc45 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -12,136 +12,6 @@ use crate::key_material::KeyMaterial; use crate::key_material::KeyType; // end of imports needed for docs -/// The basic functions of an Authenticated Encryption with Addititional Data cipher. -pub trait AEADCipher: - Algorithm + Sized -{ - #[cfg(feature = "std")] - /// A one-shot API to encrypt some plaintext with the given key, with no additional - /// authenticated data. - /// - /// This and the three that follow were the whole of the former `SymmetricCipher` trait, which - /// every symmetric cipher was once expected to implement. They now live here, because an AEAD - /// is the only kind of cipher left that needs them: a block mode reaches the same shape through - /// [`SimpleCipherEncryptor`] / [`SimpleCipherDecryptor`] and the padding adapters, and a - /// stream mode gets those traits directly. - /// - /// These are meant to be simple, easy to use, secure and fool-proof, at the cost of producing a - /// ciphertext whose layout is this implementation's business: an AEAD has a tag to put - /// somewhere, and where it goes is not fixed here. See the documentation of the underlying - /// implementation before assuming another one will read it. - /// - /// Returns the generated nonce and the ciphertext as a `Vec`, so it needs the `std` - /// feature. For AAD, use [`aead_encrypt`](Self::aead_encrypt). - fn encrypt( - key: &KeyMaterial, - plaintext: &[u8], - ) -> Result<([u8; NONCE_LEN], Vec), SymmetricCipherError>; - - /// As [`encrypt`](Self::encrypt), writing into a caller-supplied buffer so it is available - /// without `std`. - /// - /// See the documentation for the underlying implementation for how big the ciphertext buffer - /// must be; an AEAD needs room for the tag as well as the data. Returns the generated nonce and - /// the number of bytes written. - fn encrypt_out( - key: &KeyMaterial, - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError>; - - #[cfg(feature = "std")] - /// A one-shot API to decrypt what [`encrypt`](Self::encrypt) produced, with no additional - /// authenticated data. Returns the plaintext as a `Vec`, so it needs the `std` feature. - /// - /// # Errors - /// [`SymmetricCipherError::DecryptionFailed`] if the ciphertext does not authenticate. This - /// view has no AAD and no separate tag to name, so it reports every authentication failure - /// this way rather than as [`SymmetricCipherError::AEADTagCheckFailed`], which is reserved for - /// [`aead_decrypt`](Self::aead_decrypt) / [`aead_decrypt_out`](Self::aead_decrypt_out); either - /// way, the caller learns only that decryption failed, not why. - fn decrypt( - key: &KeyMaterial, - init_data: [u8; NONCE_LEN], - ciphertext: &[u8], - ) -> Result, SymmetricCipherError>; - - /// As [`decrypt`](Self::decrypt), writing into a caller-supplied buffer so it is available - /// without `std`. Returns the number of bytes written. - /// - /// # Errors - /// As [`decrypt`](Self::decrypt). - fn decrypt_out( - key: &KeyMaterial, - init_data: [u8; NONCE_LEN], - ciphertext: &[u8], - plaintext: &mut [u8], - ) -> Result; - - #[cfg(feature = "std")] - /// A one-shot API to encrypt some plaintext with the given key. - /// A distinguishing feature of AEAD ciphers is the ability to provide additional authenticated data (AAD) - /// that is not encrypted but is protected by the authentication tag; ie it can be sent along with the ciphertext - /// and any tampering with it will result in the decryption operation failing the tag check. - /// This function returns the ciphertext as a `Vec`, and therefore is only available when compiling with std. - /// Returns a tuple containing a generated nonce, the ciphertext and the tag. - fn aead_encrypt( - key: &KeyMaterial, - aad: &[u8], - plaintext: &[u8], - ) -> Result<([u8; NONCE_LEN], Vec, [u8; TAG_LEN]), SymmetricCipherError>; - /// A one-shot API to encrypt some plaintext with the given key. - /// A distinguishing feature of AEAD ciphers is the ability to provide additional authenticated data (AAD) - /// that is not encrypted but is protected by the authentication tag; ie it can be sent along with the ciphertext - /// and any tampering with it will result in the decryption operation failing the tag check. - /// Returns a tuple containing the randomly-generated nonce, number of bytes written to the ciphertext buffer, and the tag. - /// If you need a deterministic mode where you feed in the nonce, use the streaming API of [`BlockCipherEncryptor`] - /// or [`StreamCipherEncryptor`] as appropriate and feed the nonce into the IV field. - fn aead_encrypt_out( - key: &KeyMaterial, - aad: &[u8], - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError>; - /// Finishes a streaming encryption flow with an AEAD-specific `do_final()` that computes and - /// returns the authentication tag. - /// - /// An AEAD's own streaming API is [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], which has - /// this step (as [`AEADCipherEncryptor::do_encrypt_final`]) and an AAD phase of its own; this - /// method is for an implementor that streams through one of the unauthenticated cipher traits - /// -- [`BlockCipherEncryptor`] / [`BlockCipherDecryptor`] or [`StreamCipherEncryptor`] / - /// [`StreamCipherDecryptor`] -- and needs somewhere to put the tag. - fn do_aead_encrypt_final(self) -> Result<[u8; TAG_LEN], SymmetricCipherError>; - #[cfg(feature = "std")] - /// A one-shot API to decrypt some ciphertext with the given key. - /// This function returns the ciphertext as a `Vec`, and therefore is only available when compiling with std. - fn aead_decrypt( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - aad: &[u8], - ciphertext: &[u8], - tag: &[u8; TAG_LEN], - ) -> Result, SymmetricCipherError>; - /// A one-shot API to decrypt some ciphertext with the given key. - /// This function takes a reference to the output buffer for the plaintext, and is therefore available in no_std. - /// See the documentation for the underlying implementation for details on providing a plaintext buffer of sufficient size; - /// typically the ciphertext is the same length as the plaintext, but some ciphers may have an expansion factor or require - /// extra space for a nonce or tag. - /// Returns the number of bytes written to the plaintext buffer. - fn aead_decrypt_out( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - aad: &[u8], - ciphertext: &[u8], - tag: &[u8; TAG_LEN], - plaintext: &mut [u8], - ) -> Result; - /// Finishes a streaming decryption flow by checking `tag`; the mirror of - /// [`do_aead_encrypt_final`](Self::do_aead_encrypt_final), and see it for when this is the - /// right finalizer rather than [`AEADCipherDecryptor::do_decrypt_final`]. - fn do_aead_decrypt_final(self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError>; -} - /// The decryption half of an AEAD cipher's streaming API; see [`AEADCipherEncryptor`], whose notes /// on the AAD phase, buffering, and the `Result` all apply here too. /// @@ -316,8 +186,8 @@ pub trait AEADCipherDecryptor< /// consumes the encryptor, flushes whatever ciphertext it was holding back into `output`, and /// returns the tag, which the recipient needs for [`AEADCipherDecryptor::do_decrypt_final`]. Where /// the tag travels -- appended to the ciphertext, carried in a separate field -- is the caller's -/// choice, not this trait's; contrast [`AEADCipher`], whose one-shots pick a layout for you, and -/// see `bouncycastle_core::tagged_aead` for an adapter that appends it. +/// choice, not this trait's; see `bouncycastle_core::tagged_aead` for an adapter that appends it +/// to the ciphertext. /// /// Encryption and decryption are separate traits, as with [`BlockCipherEncryptor`] / /// [`BlockCipherDecryptor`], so that the direction is encoded in the type. For an AEAD that also From 80098c499e43fa0e2eeaf02ff33441726059b22c Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 19:01:23 +1000 Subject: [PATCH 09/16] release notes: record the AEADCipher removal and re-measure the mutation figures (#119) Deleting the trait and its suites takes bouncycastle-ascon from 735 mutants to 655: 558 caught, 91 unviable, 6 missed, the same six known equivalences as before, so the tests ported onto the inherent one-shots hold the coverage the deleted trait's tests had. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- alpha_0.1.3_release_notes.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index d8f83f95..e9f46a5a 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -18,11 +18,13 @@ * `core` gains the streaming AEAD split: `AEADCipherEncryptor` and `AEADCipherDecryptor<...>`, with AAD updates, exact `update_out_len`, detached tags, one-shot helpers and a `FINAL_LEN` flush buffer for implementations that hold - data back. + data back. The older single-type `core::traits::AEADCipher`, which this splits and which had + no implementors, is removed, along with its `core-test-framework` suites + (`TestFrameworkAEADCipher::test` / `::test_plain_one_shots`). * Testing covers the ASCON NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus - embedded always-on vectors. Mutation testing for `bouncycastle-ascon` reports 735 mutants, - 618 caught, 111 unviable and 6 missed; the six survivors are the sponge boundary and + embedded always-on vectors. Mutation testing for `bouncycastle-ascon` reports 655 mutants, + 558 caught, 91 unviable and 6 missed; the six survivors are the sponge boundary and `set_state_byte` OR/XOR equivalences documented at their sites. ## Minor features / bug fixes From 702246a240cfb6c60506ab336c4d47ea7cf560d6 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 19:31:52 +1000 Subject: [PATCH 10/16] core, core-test-framework, ascon, cli: carry the inline ciphertext||tag layout on the AEAD traits, and address the remaining API-shape review points (#119) The `tagged_aead` adapter pair is gone; what it did belongs to the traits themselves. - crypto/core/src/traits.rs: AEADCipherEncryptor gains `tagged_encrypt` (one-shot into `ciphertext || tag`), `tagged_do_aead_encrypt_final` (streaming: flush, then append the tag) and `tagged_encrypt_out_len`; AEADCipherDecryptor gains `tagged_decrypt`, `tagged_do_aead_decrypt_final` (streaming: the tail is leftover ciphertext followed by the tag) and `tagged_decrypt_out_max_len`. All are defaults over the existing methods, so every implementor gets both layouts and neither has to be bolted on by a wrapper type that cannot express a buffering cipher's lengths (the `FINAL_LEN = 0` restriction TaggedEncryptor and TaggedDecryptor carried). - crypto/core/src/tagged_aead.rs is deleted, with its module declaration and every use of it. crypto/core/tests/aead_tagged_tests.rs keeps the toy AEAD the deleted module's in-`src` tests used and points it at the new methods: round trip at every length crossing `TAG_LEN`, every chunking, tampering, a stream that ends before a whole tag, and every undersized buffer. - crypto/core-test-framework: the AEAD suite now checks the inline layout for every implementor (one-shot against streaming, and a too-short tail as DecryptionFailed), and the buffering toy checks it where FINAL_LEN > 0, which is where `tagged_do_aead_encrypt_final` has to flush and append in one call. Its short-buffer probe on the decryptor now feeds the decryptor its own ciphertext rather than the plaintext, and uses the ciphertext's length. - crypto/ascon: `AsconAead128::new`'s `for_encryption: bool` is no longer public API -- `new_encrypting` / `new_decrypting` name the direction, and the bool constructor they share is private. The crate docs gain a `tagged_*` example. - cli/src/ascon_cmd.rs: both directions drive the trait pair, holding the tag back by hand on the way in, which is what the adapter did for it. A failed `do_encrypt_init`/`do_decrypt_init` -- the RNG or the key material -- now prints an error and exits rather than panicking, as block_mode_cmd.rs does for the same call, and the remaining unwraps carry their `infallible:` notes. - crypto/core/src/traits.rs also: the allocating one-shot's three-part return is now the named `AEADEncrypted` (clippy `type_complexity`), and `decrypt_out` / `encrypt_out_rng` get the same "an implementor with FINAL_LEN > 0 must override this" note `encrypt_out` already had. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- alpha_0.1.3_release_notes.md | 30 +- cli/src/ascon_cmd.rs | 109 ++-- crypto/ascon/src/ascon_aead128.rs | 44 +- crypto/ascon/src/lib.rs | 23 +- crypto/ascon/tests/aead128_tests.rs | 144 +++-- crypto/ascon/tests/bc_test_data.rs | 4 +- .../src/symmetric_ciphers.rs | 89 ++- crypto/core/src/lib.rs | 1 - crypto/core/src/tagged_aead.rs | 533 ------------------ crypto/core/src/traits.rs | 161 +++++- crypto/core/tests/aead_tagged_tests.rs | 303 ++++++++++ 11 files changed, 760 insertions(+), 681 deletions(-) delete mode 100644 crypto/core/src/tagged_aead.rs create mode 100644 crypto/core/tests/aead_tagged_tests.rs diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index e9f46a5a..4de4ab1e 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -7,25 +7,29 @@ * AES -- AES-128/192/256, along with its modes AES_ECB, AES_CBC, AES_GCM. * ASCON -- Ascon-AEAD128, Ascon-Hash256, Ascon-XOF128 and Ascon-CXOF128 (NIST SP 800-232). `AsconAead128Encryptor` / `AsconAead128Decryptor` implement the generated-nonce - `AEADCipherEncryptor` / `AEADCipherDecryptor` pair, and `core::tagged_aead` adapts a - detached-tag AEAD to the common `ciphertext || tag` layout. + `AEADCipherEncryptor` / `AEADCipherDecryptor` pair; the inherent `AsconAead128` API keeps the + explicit-nonce, in-place streaming form (`new_encrypting` / `new_decrypting`). * `bouncycastle-ascon` is re-exported as `bouncycastle::ascon`; `Ascon-Hash256` and `Ascon-XOF128` are registered in the factories, and the CLI adds `ascon-hash256`, `ascon-xof128`, `ascon-cxof128` and `ascon-aead128`. The AEAD command generates and prefixes the nonce by default, with `--nonce`/`--nonce-file` retained for deterministic vectors. Streaming decrypt releases plaintext before the final tag check, so callers must discard any output if finalization or the CLI exit status reports authentication failure. - * `core` gains the streaming AEAD split: `AEADCipherEncryptor` and `AEADCipherDecryptor<...>`, with AAD updates, exact `update_out_len`, - detached tags, one-shot helpers and a `FINAL_LEN` flush buffer for implementations that hold - data back. The older single-type `core::traits::AEADCipher`, which this splits and which had - no implementors, is removed, along with its `core-test-framework` suites - (`TestFrameworkAEADCipher::test` / `::test_plain_one_shots`). - * Testing covers the ASCON NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 - Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus - embedded always-on vectors. Mutation testing for `bouncycastle-ascon` reports 655 mutants, - 558 caught, 91 unviable and 6 missed; the six survivors are the sponge boundary and - `set_state_byte` OR/XOR equivalences documented at their sites. +* `core` gains the streaming AEAD split: `AEADCipherEncryptor` and `AEADCipherDecryptor<...>`, with AAD updates, exact `update_out_len`, detached + tags, one-shot helpers and a `FINAL_LEN` flush buffer for implementations that hold data back. + The older single-type `core::traits::AEADCipher`, which this splits and which had no + implementors, is removed, along with its `core-test-framework` suites + (`TestFrameworkAEADCipher::test` / `::test_plain_one_shots`). +* The same pair carries the inline `ciphertext || tag` layout that most wire formats and files + use, as four default methods rather than a separate adapter type: `tagged_encrypt` / + `tagged_do_aead_encrypt_final` append the tag to the ciphertext stream, and `tagged_decrypt` / + `tagged_do_aead_decrypt_final` take it back off the end of one. +* ASCON testing covers the NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 Hash256, + 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus embedded always-on + vectors. Mutation testing for `bouncycastle-ascon` reports 655 mutants, 558 caught, 91 unviable + and 6 missed; the six survivors are the sponge boundary and `set_state_byte` OR/XOR equivalences + documented at their sites. ## Minor features / bug fixes diff --git a/cli/src/ascon_cmd.rs b/cli/src/ascon_cmd.rs index 3382988b..ddc87a61 100644 --- a/cli/src/ascon_cmd.rs +++ b/cli/src/ascon_cmd.rs @@ -11,8 +11,7 @@ use bouncycastle::core::errors::SymmetricCipherError; use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; -use bouncycastle::core::traits::{SecurityStrength, SimpleCipherDecryptor, SimpleCipherEncryptor}; +use bouncycastle::core::traits::{AEADCipherDecryptor, AEADCipherEncryptor, SecurityStrength}; use bouncycastle::hex; use crate::helpers; @@ -142,9 +141,9 @@ pub(crate) fn aead128_cmd( } } -/// Generated-nonce encryption: drives [`TaggedEncryptor`] over [`AsconAead128Encryptor`], writing -/// the nonce it returns ahead of the `ciphertext || tag` the adapter produces. With an explicit -/// nonce there is nothing to write, so that case goes to +/// Generated-nonce encryption: drives [`AsconAead128Encryptor`] in the inline `ciphertext || tag` +/// layout (`tagged_do_aead_encrypt_final`), writing the nonce it generated ahead of the stream. +/// With an explicit nonce there is no nonce to write, so that case goes to /// [`aead128_encrypt_stream_with_explicit_nonce`] instead. fn aead128_encrypt_stream( key: &KeyMaterial<16>, @@ -157,14 +156,14 @@ fn aead128_encrypt_stream( return; } - let (mut cipher, nonce) = as SimpleCipherEncryptor< - 16, - 16, - 16, - >>::do_encrypt_init(key) - .unwrap(); + let (mut cipher, nonce) = AsconAead128Encryptor::do_encrypt_init(key).unwrap_or_else(|e| { + eprintln!("Error: couldn't start encryption: {e:?}"); + exit(-1); + }); if let Some(ad) = ad_opt { - cipher.do_update_aad::<16, 16, 16>(ad).unwrap(); + // infallible: `do_update_aad` only refuses AAD once plaintext has been fed in, and none + // has been yet. + cipher.do_update_aad(ad).unwrap(); } helpers::write_bytes_or_hex(&nonce, output_hex); @@ -176,11 +175,15 @@ fn aead128_encrypt_stream( break; } let mut out = [0u8; 1024]; + // infallible: `out` is as long as `buf`, so it cannot be shorter than the `n` bytes read + // into it, which is the only length `IncorrectOutputBufferLength` could complain about. let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); helpers::write_bytes_or_hex(&out[..written], output_hex); } - let (tag, tag_len) = cipher.do_final().unwrap(); - helpers::write_bytes_or_hex(&tag[..tag_len], output_hex); + // infallible: Ascon-AEAD128 has FINAL_LEN = 0, so `tail` only has to hold the 16-byte tag. + let mut tail = [0u8; 16]; + let tail_len = cipher.tagged_do_aead_encrypt_final(&mut tail).unwrap(); + helpers::write_bytes_or_hex(&tail[..tail_len], output_hex); if output_hex { println!(); } @@ -196,7 +199,10 @@ fn aead128_encrypt_stream_with_explicit_nonce( ad_opt: Option<&[u8]>, output_hex: bool, ) { - let mut cipher = AsconAead128::new(key, nonce, ad_opt, true).unwrap(); + let mut cipher = AsconAead128::new_encrypting(key, nonce, ad_opt).unwrap_or_else(|e| { + eprintln!("Error: couldn't start encryption: {e:?}"); + exit(-1); + }); let mut buf = [0u8; 1024]; loop { let n = io::stdin().read(&mut buf).expect("Failed to read from stdin"); @@ -214,9 +220,10 @@ fn aead128_encrypt_stream_with_explicit_nonce( } /// Decrypts a stream whose final 16 bytes are the tag, which is only known once EOF is reached. -/// The tag-candidate hold-back this needs is [`TaggedDecryptor`]'s job, not this function's: it -/// adapts [`AsconAead128Decryptor`] to the `ciphertext || tag` layout, releasing everything but -/// the last 16 bytes it has seen as soon as it is known not to be the tag. +/// Everything but the last 16 bytes seen is released to [`AsconAead128Decryptor`] as soon as it is +/// known not to be part of the tag; what is left at EOF goes to +/// [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`], which decrypts any ciphertext still in it +/// and then checks the tag. fn aead128_decrypt_stream( key: &KeyMaterial<16>, nonce: Option<&[u8; 16]>, @@ -224,6 +231,7 @@ fn aead128_decrypt_stream( output_hex: bool, ) { const CHUNK: usize = 1024; + const TAG_LEN: usize = 16; let nonce = match nonce { Some(nonce) => *nonce, None => { @@ -239,33 +247,66 @@ fn aead128_decrypt_stream( } }; - let mut cipher = as SimpleCipherDecryptor< - 16, - 16, - 16, - >>::do_decrypt_init(key, &nonce) - .unwrap(); + let mut cipher = AsconAead128Decryptor::do_decrypt_init(key, &nonce).unwrap_or_else(|e| { + eprintln!("Error: couldn't start decryption: {e:?}"); + exit(-1); + }); if let Some(ad) = ad_opt { - cipher.do_update_aad::<16, 16>(ad).unwrap(); + // infallible: as on the encrypt side, no ciphertext has been fed in yet. + cipher.do_update_aad(ad).unwrap(); } + // The tag is the last TAG_LEN bytes of the stream, and nothing says where the stream ends + // until it does, so the last TAG_LEN bytes seen are always held back in `tail` and only + // released once something newer has arrived behind them. At EOF whatever is still in `tail` + // is the tag, which `tagged_do_aead_decrypt_final` checks. + let mut tail = [0u8; TAG_LEN]; + let mut tail_len = 0usize; let mut buf = [0u8; CHUNK]; + let mut out = [0u8; CHUNK]; loop { let n = io::stdin().read(&mut buf).expect("Failed to read from stdin"); if n == 0 { break; } - let expect = cipher.update_out_len(n); - let mut out = [0u8; CHUNK]; - // infallible: `out` is sized exactly to `update_out_len`, the only length - // `IncorrectOutputBufferLength` could complain about. - let written = cipher.do_update_out(&buf[..n], &mut out[..expect]).unwrap(); - helpers::write_bytes_or_hex(&out[..written], output_hex); + let total = tail_len + n; + if total <= TAG_LEN { + // Everything seen so far might still be the tag. + tail[tail_len..total].copy_from_slice(&buf[..n]); + tail_len = total; + continue; + } + + // Release the part of the old tail that is now known not to be the tag, then as much of + // the new input as is also known not to be; two calls over what is one contiguous run of + // ciphertext, which is the same to the cipher as one call over both. + let releasable = total - TAG_LEN; + let from_tail = tail_len.min(releasable); + let from_new = releasable - from_tail; + // infallible on both: `out` is CHUNK bytes and neither slice is longer than `buf`, and + // Ascon-AEAD128 writes exactly what it is given. + if from_tail > 0 { + let written = cipher.do_update_out(&tail[..from_tail], &mut out).unwrap(); + helpers::write_bytes_or_hex(&out[..written], output_hex); + } + if from_new > 0 { + let written = cipher.do_update_out(&buf[..from_new], &mut out).unwrap(); + helpers::write_bytes_or_hex(&out[..written], output_hex); + } + + // Whatever was not released is the new tail: the end of the old one, then the end of this + // read. Those are exactly TAG_LEN bytes, since `total - releasable == TAG_LEN`. + let mut new_tail = [0u8; TAG_LEN]; + let kept = tail_len - from_tail; + new_tail[..kept].copy_from_slice(&tail[from_tail..tail_len]); + new_tail[kept..].copy_from_slice(&buf[from_new..n]); + tail = new_tail; + tail_len = TAG_LEN; } - match cipher.do_final() { - Ok((last, last_len)) => { - helpers::write_bytes_or_hex(&last[..last_len], output_hex); + match cipher.tagged_do_aead_decrypt_final(&tail[..tail_len], &mut out) { + Ok(last_len) => { + helpers::write_bytes_or_hex(&out[..last_len], output_hex); if output_hex { println!(); } diff --git a/crypto/ascon/src/ascon_aead128.rs b/crypto/ascon/src/ascon_aead128.rs index b2d628d1..9a0fdfd0 100644 --- a/crypto/ascon/src/ascon_aead128.rs +++ b/crypto/ascon/src/ascon_aead128.rs @@ -11,8 +11,9 @@ //! full 16-byte block has been absorbed, or at finalization. //! //! [`AsconAead128Encryptor`] / [`AsconAead128Decryptor`] adapt this type's direction-agnostic -//! streaming API (a single [`AsconAead128`] value serves either direction, chosen by a runtime -//! flag to [`AsconAead128::new`]) to [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], whose +//! streaming API (a single [`AsconAead128`] value serves either direction, fixed at construction +//! by [`AsconAead128::new_encrypting`] / [`AsconAead128::new_decrypting`]) to +//! [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], whose //! direction is fixed by the type: each newtype wraps an [`AsconAead128`] already constructed for //! its own direction and only ever calls that direction's inherent methods, so the wrong-direction //! panics inside [`AsconAead128::do_encrypt_update`] and friends are unreachable through them. See @@ -93,7 +94,8 @@ impl StateMachine { /// An implementation of the Ascon-AEAD128 algorithm (NIST SP 800-232). /// /// A single instance performs one operation (encryption or decryption) under one (key, nonce) pair. -/// See [`AsconAead128::new`] for the streaming workflow and [`AsconAead128::encrypt`] / +/// See [`AsconAead128::new_encrypting`] for the streaming workflow and +/// [`AsconAead128::encrypt`] / /// [`AsconAead128::decrypt`] for the one-shot APIs. #[derive(Clone)] pub struct AsconAead128 { @@ -138,7 +140,7 @@ impl AsconAead128 { /// The one-shot APIs of main's cipher framework generate the init data / nonce internally, so /// Ascon's per-encryption nonce-uniqueness requirement (SP 800-232 R3) is satisfied by sourcing /// each nonce from a CSPRNG. Callers who need deterministic, caller-supplied nonces should use - /// the inherent streaming API ([`AsconAead128::new`]). + /// the inherent streaming API ([`AsconAead128::new_encrypting`]). fn fresh_nonce() -> Result<[u8; NONCE_LEN], SymmetricCipherError> { let mut rng = HashDRBG_SHA512::new_from_os(); let mut nonce = [0u8; NONCE_LEN]; @@ -146,12 +148,38 @@ impl AsconAead128 { Ok(nonce) } - /// Create a new streaming instance. + /// Creates a streaming instance for **encryption** under a caller-supplied nonce. /// * `key` is validated as a [`KeyType::SymmetricCipherKey`] with at least 128-bit strength. - /// * `nonce` is the 128-bit nonce. It **must** be unique per encryption under a given key. + /// * `nonce` is the 128-bit nonce. It **must** be unique per encryption under a given key; + /// [`AsconAead128Encryptor`] generates one instead, which is the safer default. /// * `ad` is optional associated data (authenticated, not encrypted); processed immediately. - /// * `for_encryption` is true for encryption, false for decryption. - pub fn new( + /// + /// Only the `do_encrypt_*` methods may be called on the result; the decrypting ones panic. + pub fn new_encrypting( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ad: Option<&[u8]>, + ) -> Result { + Self::new(key, nonce, ad, true) + } + + /// Creates a streaming instance for **decryption** under the nonce the ciphertext was produced + /// with; see [`new_encrypting`](Self::new_encrypting) for the arguments. + /// + /// Only the `do_decrypt_*` methods may be called on the result; the encrypting ones panic. + pub fn new_decrypting( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ad: Option<&[u8]>, + ) -> Result { + Self::new(key, nonce, ad, false) + } + + /// The body of [`new_encrypting`](Self::new_encrypting) / [`new_decrypting`](Self::new_decrypting). + /// Private because a `bool` for the direction is not something the public API should ask a + /// caller to get right: every public entry point fixes it, either by name here or by type on + /// [`AsconAead128Encryptor`] / [`AsconAead128Decryptor`]. + fn new( key: &KeyMaterial, nonce: &[u8; NONCE_LEN], ad: Option<&[u8]>, diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index cf3615f4..017aad46 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -74,9 +74,26 @@ //! assert_eq!(&recovered, plaintext); //! ``` //! -//! For the inline `ciphertext || tag` layout, wrap the pair in -//! [`bouncycastle_core::tagged_aead::TaggedEncryptor`] / -//! [`bouncycastle_core::tagged_aead::TaggedDecryptor`]. +//! For the inline `ciphertext || tag` layout that most wire formats and files use, the same pair +//! has [`bouncycastle_core::traits::AEADCipherEncryptor::tagged_encrypt`] / +//! [`bouncycastle_core::traits::AEADCipherDecryptor::tagged_decrypt`] as one-shots, and +//! `tagged_do_aead_encrypt_final` / `tagged_do_aead_decrypt_final` for streaming: +//! ``` +//! use bouncycastle_ascon::ascon_aead128::{AsconAead128Decryptor, AsconAead128Encryptor}; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); +//! let plaintext = b"secret message!!"; +//! +//! let mut inline = [0u8; 32]; // AsconAead128Encryptor::tagged_encrypt_out_len(16) +//! let (nonce, len) = AsconAead128Encryptor::tagged_encrypt(&key, b"", plaintext, &mut inline).unwrap(); +//! assert_eq!(len, plaintext.len() + 16); // ciphertext || tag +//! +//! let mut recovered = [0u8; 16]; +//! let n = AsconAead128Decryptor::tagged_decrypt(&key, &nonce, b"", &inline[..len], &mut recovered).unwrap(); +//! assert_eq!(&recovered[..n], plaintext); +//! ``` //! //! Extendable output: //! ``` diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs index 15387da1..c387faac 100644 --- a/crypto/ascon/tests/aead128_tests.rs +++ b/crypto/ascon/tests/aead128_tests.rs @@ -5,9 +5,8 @@ //! - Behavioral / contract tests (round-trips, streaming chunk-boundary equivalence, authentication //! failures, determinism), driven through the inherent explicit-nonce API. //! - The shared conformance framework (`core-test-framework`), which exercises the -//! `AEADCipherEncryptor`/`AEADCipherDecryptor` pair and, through `TaggedEncryptor`/ -//! `TaggedDecryptor`, the `SimpleCipherEncryptor`/`SimpleCipherDecryptor` surface, both with -//! internally-generated nonces. +//! `AEADCipherEncryptor`/`AEADCipherDecryptor` pair, with internally-generated nonces, in both +//! the detached-tag and the inline `ciphertext || tag` (`tagged_*`) layouts. use bouncycastle_ascon::ascon_aead128::{ AsconAead128, AsconAead128Decryptor, AsconAead128Encryptor, @@ -17,9 +16,7 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::SecurityStrength; -use bouncycastle_core_test_framework::symmetric_ciphers::{ - TestFrameworkAEADCipher, TestFrameworkSimpleCipher, -}; +use bouncycastle_core_test_framework::symmetric_ciphers::TestFrameworkAEADCipher; use bouncycastle_hex as hex; // All embedded vectors use this fixed key/nonce (the NIST LWC KAT convention). @@ -110,7 +107,7 @@ fn dec_oneshot( fn enc_chunked(key: &[u8; 16], nonce: &[u8; 16], ad: &[u8], pt: &[u8], chunk: usize) -> Vec { let km = key_material(key); - let mut cipher = AsconAead128::new(&km, nonce, ad_opt(ad), true).unwrap(); + let mut cipher = AsconAead128::new_encrypting(&km, nonce, ad_opt(ad)).unwrap(); let mut out = vec![0u8; pt.len() + 16]; out[..pt.len()].copy_from_slice(pt); @@ -134,7 +131,7 @@ fn dec_chunked( chunk: usize, ) -> Result, SymmetricCipherError> { let km = key_material(key); - let mut cipher = AsconAead128::new(&km, nonce, ad_opt(ad), false).unwrap(); + let mut cipher = AsconAead128::new_decrypting(&km, nonce, ad_opt(ad)).unwrap(); let pt_len = ct.len() - 16; let mut out = vec![0u8; pt_len]; out.copy_from_slice(&ct[..pt_len]); @@ -231,7 +228,7 @@ fn aead_chunked_aad_matches_one_shot() { let km = key_material(&KEY); for &chunk in CHUNK_SIZES.iter() { - let mut e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + let mut e = AsconAead128::new_encrypting(&km, &NONCE, None).unwrap(); for piece in ad.chunks(chunk) { e.do_update_aad(piece).unwrap(); } @@ -260,7 +257,7 @@ fn aead_streaming_chunk_sweep() { let (ct_ref_body, tag_ref) = ct_ref.split_at(pt_len); for &chunk in [1, 2, 7, 15, 16, 17, 31, 32, 1024].iter() { - let mut e = AsconAead128::new(&km, &NONCE, ad_opt_, true).unwrap(); + let mut e = AsconAead128::new_encrypting(&km, &NONCE, ad_opt_).unwrap(); let mut out = pt.clone(); let chunk = chunk.max(1); let mut off = 0; @@ -273,7 +270,7 @@ fn aead_streaming_chunk_sweep() { assert_eq!(out, ct_ref_body, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); assert_eq!(tag, tag_ref, "pt_len={pt_len} ad_len={ad_len} chunk={chunk}"); - let mut d = AsconAead128::new(&km, &NONCE, ad_opt_, false).unwrap(); + let mut d = AsconAead128::new_decrypting(&km, &NONCE, ad_opt_).unwrap(); let mut back = ct_ref_body.to_vec(); let mut off = 0; while off < back.len() { @@ -293,7 +290,7 @@ fn aead_streaming_chunk_sweep() { fn do_decrypt_final_rejects_wrong_tag() { let km = key_material(&KEY); let pt = pattern(20); - let mut d = AsconAead128::new(&km, &NONCE, None, false).unwrap(); + let mut d = AsconAead128::new_decrypting(&km, &NONCE, None).unwrap(); let mut buf = pt.clone(); d.do_decrypt_update(&mut buf); let wrong_tag = [0xFFu8; 16]; @@ -446,7 +443,7 @@ fn aead_is_deterministic_and_nonce_sensitive() { #[test] fn aead_debug_display_are_masked() { let km = key_material(&KEY); - let e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + let e = AsconAead128::new_encrypting(&km, &NONCE, None).unwrap(); assert!(format!("{e:?}").contains("masked")); assert!(format!("{e}").contains("masked")); } @@ -459,7 +456,7 @@ fn aead_debug_display_are_masked() { #[should_panic(expected = "decryptor")] fn do_encrypt_update_on_decryptor_panics() { let km = key_material(&KEY); - let mut d = AsconAead128::new(&km, &NONCE, None, false).unwrap(); + let mut d = AsconAead128::new_decrypting(&km, &NONCE, None).unwrap(); let mut buf = [0u8; 4]; d.do_encrypt_update(&mut buf); } @@ -468,7 +465,7 @@ fn do_encrypt_update_on_decryptor_panics() { #[should_panic(expected = "encryptor")] fn do_decrypt_update_on_encryptor_panics() { let km = key_material(&KEY); - let mut e = AsconAead128::new(&km, &NONCE, None, true).unwrap(); + let mut e = AsconAead128::new_encrypting(&km, &NONCE, None).unwrap(); let mut buf = [0u8; 4]; e.do_decrypt_update(&mut buf); } @@ -495,48 +492,23 @@ fn aead_framework_buffering_toy() { TestFrameworkAEADCipher::new().test_buffering_toy(); } -/// The inline-tag adapter ([`TaggedEncryptor`]/[`TaggedDecryptor`]) over the same -/// [`AsconAead128Encryptor`]/[`AsconAead128Decryptor`] pair must pass the unrelated -/// [`SimpleCipherEncryptor`]/[`SimpleCipherDecryptor`] conformance suite -- proof that adapting an -/// AEAD to the `ciphertext || tag` layout costs nothing beyond appending the tag. -/// -/// [`TaggedEncryptor`]: bouncycastle_core::tagged_aead::TaggedEncryptor -/// [`TaggedDecryptor`]: bouncycastle_core::tagged_aead::TaggedDecryptor -/// [`SimpleCipherEncryptor`]: bouncycastle_core::traits::SimpleCipherEncryptor -/// [`SimpleCipherDecryptor`]: bouncycastle_core::traits::SimpleCipherDecryptor -#[test] -fn aead128_tagged_adapter_passes_simple_cipher_framework() { - use bouncycastle_core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; - - TestFrameworkSimpleCipher::new().test_encryptor_decryptor::< - 16, - 16, - 16, - TaggedEncryptor, - TaggedDecryptor, - >(); -} - /// The two tag layouts must agree byte for byte: `direct_ciphertext || direct_tag`, produced by -/// streaming [`AsconAead128Encryptor`] directly, must equal what streaming through -/// [`TaggedEncryptor`] gives for the same key, nonce (driven by the same RNG stream), AAD and -/// message -- and the reverse must decrypt either back to the original plaintext. -/// -/// [`TaggedEncryptor`]: bouncycastle_core::tagged_aead::TaggedEncryptor +/// streaming [`AsconAead128Encryptor`] and taking the tag from `do_encrypt_final`, must equal what +/// the inline layout produces for the same key, nonce (driven by the same RNG stream), AAD and +/// message -- through both `tagged_encrypt` and `tagged_do_aead_encrypt_final` -- and either must +/// decrypt back to the original plaintext. #[test] fn aead128_tagged_and_direct_layouts_agree() { - use bouncycastle_core::tagged_aead::{TaggedDecryptor, TaggedEncryptor}; - use bouncycastle_core::traits::{ - AEADCipherDecryptor, AEADCipherEncryptor, SimpleCipherDecryptor, SimpleCipherEncryptor, - }; + use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; use bouncycastle_core_test_framework::FixedSeedRNG; let km = key_material(&KEY); - let aad = b"tagged-adapter-aad"; + let aad = b"tagged-layout-aad"; for pt_len in [0usize, 1, 15, 16, 17, 40] { let pt = pattern(pt_len); let pinned = [0x11u8; 16]; + // detached tag, streamed let (mut direct_enc, direct_nonce) = AsconAead128Encryptor::do_encrypt_init_rng(&km, &mut FixedSeedRNG::<16>::new(pinned)) .unwrap(); @@ -544,53 +516,65 @@ fn aead128_tagged_and_direct_layouts_agree() { let mut direct_ct = vec![0u8; pt.len()]; direct_enc.do_update_out(&pt, &mut direct_ct).unwrap(); let mut nothing = [0u8; 0]; - let (_flushed, direct_tag) = direct_enc.do_encrypt_final(&mut nothing).unwrap(); + let (_, direct_tag) = direct_enc.do_encrypt_final(&mut nothing).unwrap(); let mut direct_inline = direct_ct.clone(); direct_inline.extend_from_slice(&direct_tag); + // inline tag, streamed let (mut tagged_enc, tagged_nonce) = - as SimpleCipherEncryptor<16, 16, 16>>::do_encrypt_init_rng( - &km, - &mut FixedSeedRNG::<16>::new(pinned), - ) - .unwrap(); - tagged_enc.do_update_aad::<16, 16, 16>(aad).unwrap(); - let mut tagged_out = vec![0u8; pt.len() + 16]; - let written = tagged_enc.do_update_out(&pt, &mut tagged_out).unwrap(); - let mut last = [0u8; 16]; - let last_len = as SimpleCipherEncryptor< - 16, - 16, - 16, - >>::do_final_out(tagged_enc, &mut last) - .unwrap(); - tagged_out[written..written + last_len].copy_from_slice(&last[..last_len]); - tagged_out.truncate(written + last_len); + AsconAead128Encryptor::do_encrypt_init_rng(&km, &mut FixedSeedRNG::<16>::new(pinned)) + .unwrap(); + tagged_enc.do_update_aad(aad).unwrap(); + let mut tagged_out = vec![0u8; AsconAead128Encryptor::tagged_encrypt_out_len(pt.len())]; + let mut written = tagged_enc.do_update_out(&pt, &mut tagged_out).unwrap(); + written += tagged_enc.tagged_do_aead_encrypt_final(&mut tagged_out[written..]).unwrap(); + tagged_out.truncate(written); assert_eq!(direct_nonce, tagged_nonce, "pt_len {pt_len}: same RNG stream, same nonce"); assert_eq!(direct_inline, tagged_out, "pt_len {pt_len}: inline layout must agree"); - // ...and both decrypt back to the original plaintext, each through its own view. + // inline tag, one-shot: its own generated nonce, so what must match is the round trip + // and the length, not the bytes. + let mut one_shot = vec![0u8; AsconAead128Encryptor::tagged_encrypt_out_len(pt.len())]; + let (one_nonce, one_len) = + AsconAead128Encryptor::tagged_encrypt(&km, aad, &pt, &mut one_shot).unwrap(); + assert_eq!(one_len, tagged_out.len(), "pt_len {pt_len}: one-shot writes the same length"); + let mut one_back = vec![0u8; AsconAead128Decryptor::tagged_decrypt_out_max_len(one_len)]; + let one_n = AsconAead128Decryptor::tagged_decrypt( + &km, + &one_nonce, + aad, + &one_shot[..one_len], + &mut one_back, + ) + .unwrap(); + assert_eq!(&one_back[..one_n], &pt[..], "pt_len {pt_len}: one-shot round trip"); + + // ...and all of it decrypts back, each through its own view. let mut direct_dec = AsconAead128Decryptor::do_decrypt_init(&km, &direct_nonce).unwrap(); direct_dec.do_update_aad(aad).unwrap(); let mut direct_pt = vec![0u8; direct_ct.len()]; direct_dec.do_update_out(&direct_ct, &mut direct_pt).unwrap(); - let tag_arr: [u8; 16] = direct_tag; - direct_dec.do_decrypt_final(&tag_arr, &mut nothing).unwrap(); + direct_dec.do_decrypt_final(&direct_tag, &mut nothing).unwrap(); assert_eq!(direct_pt, pt, "pt_len {pt_len}: direct decrypt round trip"); - let mut tagged_dec = as SimpleCipherDecryptor< - 16, - 16, - 16, - >>::do_decrypt_init(&km, &tagged_nonce) - .unwrap(); - tagged_dec.do_update_aad::<16, 16>(aad).unwrap(); + let mut tagged_dec = AsconAead128Decryptor::do_decrypt_init(&km, &tagged_nonce).unwrap(); + tagged_dec.do_update_aad(aad).unwrap(); + let body = tagged_out.len() - 16; let mut tagged_pt = vec![0u8; tagged_out.len()]; - let written = tagged_dec.do_update_out(&tagged_out, &mut tagged_pt).unwrap(); - let (_, final_data_len) = tagged_dec.do_final().unwrap(); - tagged_pt.truncate(written + final_data_len); - assert_eq!(tagged_pt, pt, "pt_len {pt_len}: tagged decrypt round trip"); + let mut got = tagged_dec.do_update_out(&tagged_out[..body], &mut tagged_pt).unwrap(); + got += tagged_dec + .tagged_do_aead_decrypt_final(&tagged_out[body..], &mut tagged_pt[got..]) + .unwrap(); + assert_eq!(&tagged_pt[..got], &pt[..], "pt_len {pt_len}: tagged decrypt round trip"); + + let mut one_pt = + vec![0u8; AsconAead128Decryptor::tagged_decrypt_out_max_len(tagged_out.len())]; + let n = AsconAead128Decryptor::tagged_decrypt( + &km, &tagged_nonce, aad, &tagged_out, &mut one_pt, + ) + .unwrap(); + assert_eq!(&one_pt[..n], &pt[..], "pt_len {pt_len}: streamed ciphertext, one-shot decrypt"); } } @@ -607,7 +591,7 @@ fn aead128_suspendable_keyed_state() { // Encrypt part of the plaintext, suspend, resume with the re-supplied key, finish, and confirm // the output matches a one-shot encryption. The key is never part of the serialized state. - let mut e = AsconAead128::new(&km, &NONCE, Some(ad), true).unwrap(); + let mut e = AsconAead128::new_encrypting(&km, &NONCE, Some(ad)).unwrap(); let mut out = vec![0u8; pt.len() + 16]; out[..pt.len()].copy_from_slice(&pt); e.do_encrypt_update(&mut out[..18]); diff --git a/crypto/ascon/tests/bc_test_data.rs b/crypto/ascon/tests/bc_test_data.rs index 44305e7a..d35eaa1b 100644 --- a/crypto/ascon/tests/bc_test_data.rs +++ b/crypto/ascon/tests/bc_test_data.rs @@ -168,7 +168,7 @@ mod bc_test_data { assert_eq!(pt_out, pt, "decrypt mismatch (Count {})", field(case, &["Count"])); // Byte-at-a-time streaming encrypt/decrypt, through the inherent API. - let mut enc = AsconAead128::new(&key, &nonce, ad_opt, true).unwrap(); + let mut enc = AsconAead128::new_encrypting(&key, &nonce, ad_opt).unwrap(); let mut stream_ct = pt.clone(); for byte in stream_ct.iter_mut() { @@ -185,7 +185,7 @@ mod bc_test_data { field(case, &["Count"]) ); - let mut dec = AsconAead128::new(&key, &nonce, ad_opt, false).unwrap(); + let mut dec = AsconAead128::new_decrypting(&key, &nonce, ad_opt).unwrap(); let mut stream_pt = expected_ct[..pt.len()].to_vec(); for byte in stream_pt.iter_mut() { diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 407910ff..37c9c8ee 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -531,6 +531,58 @@ impl TestFrameworkAEADCipher { let pt3 = D::decrypt(&key, &nonce, aad, &ct, &tag).unwrap(); assert_eq!(pt3, msg, "decrypt must agree with decrypt_out"); + // the inline `ciphertext || tag` layout: `tagged_encrypt` must write exactly the + // separate-tag ciphertext with the tag appended, and both the one-shot and the + // streaming finalizer must round trip it. + let mut inline = vec![0u8; E::tagged_encrypt_out_len(len)]; + let (inline_nonce, inline_len) = + E::tagged_encrypt(&key, aad, msg, &mut inline).unwrap(); + assert_eq!( + inline_len, + E::encrypt_out_len(len) + TAG_LEN, + "tagged_encrypt must write the ciphertext plus the tag, len {len}" + ); + let mut pt4 = vec![0u8; D::tagged_decrypt_out_max_len(inline_len)]; + let pt4_len = + D::tagged_decrypt(&key, &inline_nonce, aad, &inline[..inline_len], &mut pt4) + .unwrap(); + assert_eq!(&pt4[..pt4_len], msg, "tagged one-shot round trip, len {len}"); + + let (mut enc5, nonce5) = E::do_encrypt_init(&key).unwrap(); + enc5.do_update_aad(aad).unwrap(); + // `+ FINAL_LEN`: the finalizer wants room for a full flush plus the tag at the tail, + // which it cannot know the size of before it runs. + let mut inline5 = vec![0u8; E::tagged_encrypt_out_len(len) + FINAL_LEN]; + let mut written5 = enc5.do_update_out(msg, &mut inline5).unwrap(); + written5 += enc5.tagged_do_aead_encrypt_final(&mut inline5[written5..]).unwrap(); + assert_eq!( + written5, inline_len, + "tagged streaming must write as much as the one-shot, len {len}" + ); + let body5 = written5 - TAG_LEN; + let mut dec5 = D::do_decrypt_init(&key, &nonce5).unwrap(); + dec5.do_update_aad(aad).unwrap(); + let mut pt5 = vec![0u8; written5 + FINAL_LEN]; + let mut got5 = dec5.do_update_out(&inline5[..body5], &mut pt5).unwrap(); + got5 += dec5 + .tagged_do_aead_decrypt_final(&inline5[body5..written5], &mut pt5[got5..]) + .unwrap(); + assert_eq!(&pt5[..got5], msg, "tagged streaming round trip, len {len}"); + + // a stream that ends before a whole tag has been seen is not a short buffer, it is a + // failed decryption + if TAG_LEN > 0 { + let dec6 = D::do_decrypt_init(&key, &nonce5).unwrap(); + let mut scratch = vec![0u8; written5 + FINAL_LEN]; + assert!( + matches!( + dec6.tagged_do_aead_decrypt_final(&inline5[..TAG_LEN - 1], &mut scratch), + Err(SymmetricCipherError::DecryptionFailed) + ), + "a tail shorter than the tag must be DecryptionFailed, len {len}" + ); + } + // too-short output buffers on the one-shots are refused with the required length, // before any work is done let need = E::encrypt_out_len(len); @@ -638,16 +690,16 @@ impl TestFrameworkAEADCipher { } } - let (mut dec, _) = { + let (mut dec, ct) = { let (mut enc, nonce) = E::do_encrypt_init(&key).unwrap(); let mut ct = vec![0u8; enc.update_out_len(msg.len())]; enc.do_update_out(msg, &mut ct).unwrap(); (D::do_decrypt_init(&key, &nonce).unwrap(), ct) }; - let need = dec.update_out_len(msg.len()); + let need = dec.update_out_len(ct.len()); if need > 0 { let mut short = vec![0u8; need - 1]; - match dec.do_update_out(msg, &mut short) { + match dec.do_update_out(&ct, &mut short) { Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => { assert_eq!(n, need) } @@ -1025,6 +1077,37 @@ impl TestFrameworkAEADCipher { assert_eq!(pt, msg, "len {len} chunk {chunk}: round trip"); } + // The inline `ciphertext || tag` layout, which is where a buffering cipher makes + // `tagged_do_aead_encrypt_final` do two things at once: flush the held-back bytes and + // then append the tag after them. + let (mut enc, nonce) = Enc::do_encrypt_init(&key).unwrap(); + // `+ HOLD_BACK`: see the same sizing in `test_encryptor_decryptor`. + let mut inline = vec![0u8; Enc::tagged_encrypt_out_len(len) + HOLD_BACK]; + let mut written = enc.do_update_out(msg, &mut inline).unwrap(); + assert!(written < len || len == 0, "len {len}: the toy must be holding something back"); + written += enc.tagged_do_aead_encrypt_final(&mut inline[written..]).unwrap(); + assert_eq!( + written, + len + TAG_LEN, + "len {len}: inline layout is the message plus a tag" + ); + + let body = written - TAG_LEN; + let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); + let mut pt = vec![0u8; written + HOLD_BACK]; + let mut got = dec.do_update_out(&inline[..body], &mut pt).unwrap(); + got += + dec.tagged_do_aead_decrypt_final(&inline[body..written], &mut pt[got..]).unwrap(); + assert_eq!(&pt[..got], msg, "len {len}: inline streaming round trip"); + + let mut one = vec![0u8; Enc::tagged_encrypt_out_len(len)]; + let (one_nonce, one_len) = Enc::tagged_encrypt(&key, b"", msg, &mut one).unwrap(); + assert_eq!(&one[..one_len], &inline[..written], "len {len}: one-shot must agree"); + let mut back = vec![0u8; Dec::tagged_decrypt_out_max_len(one_len) + HOLD_BACK]; + let back_len = + Dec::tagged_decrypt(&key, &one_nonce, b"", &one[..one_len], &mut back).unwrap(); + assert_eq!(&back[..back_len], msg, "len {len}: inline one-shot round trip"); + // For any length past the hold-back window, at least one prefix of the input must be // held back rather than released immediately -- the property this whole test exists // to pin. (For `len < HOLD_BACK` nothing is ever releasable until `do_encrypt_final`, diff --git a/crypto/core/src/lib.rs b/crypto/core/src/lib.rs index 53460b5c..a75792dc 100644 --- a/crypto/core/src/lib.rs +++ b/crypto/core/src/lib.rs @@ -9,5 +9,4 @@ pub mod errors; pub mod key_material; pub mod suspendable_state; -pub mod tagged_aead; pub mod traits; diff --git a/crypto/core/src/tagged_aead.rs b/crypto/core/src/tagged_aead.rs deleted file mode 100644 index 1d49aad1..00000000 --- a/crypto/core/src/tagged_aead.rs +++ /dev/null @@ -1,533 +0,0 @@ -//! Adapts an [`AEADCipherEncryptor`] / -//! [`AEADCipherDecryptor`] pair to the separate-output -//! [`SimpleCipherEncryptor`] / -//! [`SimpleCipherDecryptor`] shape by inlining the tag as -//! the last `TAG_LEN` bytes of the ciphertext stream -- the `ciphertext || tag` layout most wire -//! formats and files use, as opposed to the AEAD pair's own detached-tag shape. -//! -//! This is deliberately the *inverse* direction from every other adapter in this crate: instead -//! of adding capability (an AEAD's AAD, its generated nonce), it *drops* the AAD phase, because -//! [`SimpleCipherEncryptor`] has nowhere to carry one. An -//! AEAD wrapped here can still be driven with AAD through the inherent -//! [`TaggedEncryptor::do_update_aad`] / [`TaggedDecryptor::do_update_aad`], which forward to the -//! wrapped value's own method (see their docs for why this can't be part of the -//! `SimpleCipherEncryptor`/`SimpleCipherDecryptor` impl itself); a caller who does not need AAD -//! can ignore that entirely and use [`SimpleCipherEncryptor`]'s -//! full one-shot and streaming API unchanged. -//! -//! # Restricted to non-buffering ciphers -//! -//! Both adapters require the wrapped `FINAL_LEN` to be `0` -- nothing held back at -//! finalization -- which covers Ascon-AEAD128 and any other AEAD that releases every ciphertext -//! byte as soon as it produces it. A cipher that also buffers a partial final block would need -//! this adapter's own `FINAL_LEN` to be `INNER_FINAL_LEN + TAG_LEN`, a value derived from two -//! other const generics; Rust's stable const generics cannot express that as a trait argument -//! (it needs the still-incomplete `generic_const_exprs`), so supporting it is left to a future, -//! more general adapter. - -use crate::errors::SymmetricCipherError; -use crate::key_material::KeyMaterial; -use crate::traits::{ - AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, - SimpleCipherDecryptor, SimpleCipherEncryptor, -}; - -/// Adapts an [`AEADCipherEncryptor`] with `FINAL_LEN = 0` to -/// [`SimpleCipherEncryptor`], appending the tag as the final segment -/// so the output stream is `ciphertext || tag`. See the module docs for the AAD caveat and the -/// `FINAL_LEN = 0` restriction. -pub struct TaggedEncryptor(E); - -impl TaggedEncryptor { - /// Absorbs `aad` on the wrapped encryptor; see - /// [`AEADCipherEncryptor::do_update_aad`] - /// for the rules (repeatable before the first `do_update_out`, an empty slice always a no-op). - /// Not part of the [`SimpleCipherEncryptor`] impl below, which has no AAD concept at all. - pub fn do_update_aad( - &mut self, - aad: &[u8], - ) -> Result<(), SymmetricCipherError> - where - E: AEADCipherEncryptor, - { - self.0.do_update_aad(aad) - } -} - -// Bounded on `Algorithm` alone, not the full `AEADCipherEncryptor` -// used below: those three consts appear only in a `where` clause, which Rust's coherence check -// does not accept as constraining an impl's generic parameters (E0207), and `Algorithm`'s own -// consts do not need them. -impl Algorithm for TaggedEncryptor { - const ALG_NAME: &'static str = E::ALG_NAME; - const MAX_SECURITY_STRENGTH: SecurityStrength = E::MAX_SECURITY_STRENGTH; -} - -impl - SimpleCipherEncryptor for TaggedEncryptor -where - E: AEADCipherEncryptor, -{ - fn do_encrypt_init( - key: &KeyMaterial, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { - let (inner, nonce) = E::do_encrypt_init(key)?; - Ok((Self(inner), nonce)) - } - - fn do_encrypt_init_rng( - key: &KeyMaterial, - rng: &mut dyn RNG, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { - let (inner, nonce) = E::do_encrypt_init_rng(key, rng)?; - Ok((Self(inner), nonce)) - } - - /// Identical to the wrapped encryptor's: this adapter never itself buffers, since the tag has - /// nowhere to go until `do_final`. - fn update_out_len(&self, input_len: usize) -> usize { - self.0.update_out_len(input_len) - } - - fn do_update_out( - &mut self, - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result { - self.0.do_update_out(plaintext, ciphertext) - } - - /// Finishes the inner encryptor (with an empty flush buffer, since `FINAL_LEN = 0` on the - /// bound above) and returns its tag as this trait's own `FINAL_LEN`-byte final segment. - fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { - let mut nothing = [0u8; 0]; - let (flushed, tag) = self.0.do_encrypt_final(&mut nothing)?; - if flushed != 0 { - return Err(SymmetricCipherError::GenericError( - "AEAD with FINAL_LEN = 0 flushed data at finalization", - )); - } - Ok((tag, TAG_LEN)) - } - - /// The plaintext length plus the tag: the inline layout this adapter produces. - fn encrypt_out_len(plaintext_len: usize) -> usize { - plaintext_len + TAG_LEN - } -} - -/// Adapts an [`AEADCipherDecryptor`] with `FINAL_LEN = 0` to -/// [`SimpleCipherDecryptor`], reading the tag as the last `TAG_LEN` -/// bytes of the ciphertext stream. `FINAL_LEN` here is `TAG_LEN` only to match -/// [`TaggedEncryptor`]'s own `FINAL_LEN` -- the pair contract [`SimpleCipherEncryptor`] / -/// [`SimpleCipherDecryptor`] share -- not because anything is actually flushed; see this type's -/// `do_final` impl. See the module docs for the AAD caveat and the wrapped AEAD's own -/// `FINAL_LEN = 0` restriction. -/// -/// # Holding back the tag -/// -/// The wire format gives no advance notice of where the ciphertext ends and the tag begins -- -/// that boundary is only known once the whole stream has been seen -- so this type holds back the -/// last `TAG_LEN` bytes it has been given at all times, in `tail`, releasing everything older than -/// that through the wrapped decryptor as soon as it is known not to be part of the tag. This is -/// the same technique `cli/src/ascon_cmd.rs`'s `aead128_decrypt_stream` used by hand before this -/// adapter existed. -pub struct TaggedDecryptor { - inner: D, - tail: [u8; TAG_LEN], - tail_len: usize, -} - -impl TaggedDecryptor { - /// Absorbs `aad` on the wrapped decryptor; see - /// [`AEADCipherDecryptor::do_update_aad`] - /// for the rules. Not part of the [`SimpleCipherDecryptor`] impl below, which has no AAD - /// concept at all. - pub fn do_update_aad( - &mut self, - aad: &[u8], - ) -> Result<(), SymmetricCipherError> - where - D: AEADCipherDecryptor, - { - self.inner.do_update_aad(aad) - } -} - -// See the equivalent impl on `TaggedEncryptor` for why this bounds on `Algorithm` alone. -impl Algorithm for TaggedDecryptor { - const ALG_NAME: &'static str = D::ALG_NAME; - const MAX_SECURITY_STRENGTH: SecurityStrength = D::MAX_SECURITY_STRENGTH; -} - -impl - SimpleCipherDecryptor for TaggedDecryptor -where - D: AEADCipherDecryptor, -{ - fn do_decrypt_init( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - ) -> Result { - Ok(Self { inner: D::do_decrypt_init(key, nonce)?, tail: [0u8; TAG_LEN], tail_len: 0 }) - } - - /// Only the bytes no longer eligible to be the tag: `tail_len + input_len - TAG_LEN`, floored - /// at `0` while the stream is still shorter than the tag itself. - fn update_out_len(&self, input_len: usize) -> usize { - (self.tail_len + input_len).saturating_sub(TAG_LEN) - } - - fn do_update_out( - &mut self, - ciphertext: &[u8], - plaintext: &mut [u8], - ) -> Result { - let releasable = self.update_out_len(ciphertext.len()); - if plaintext.len() < releasable { - return Err(SymmetricCipherError::IncorrectOutputBufferLength("plaintext", releasable)); - } - - let total = self.tail_len + ciphertext.len(); - if total <= TAG_LEN { - // Everything seen so far might still be the tag; buffer it and release nothing. - self.tail[self.tail_len..total].copy_from_slice(ciphertext); - self.tail_len = total; - return Ok(0); - } - - // Release the old tail (in full, or as much of it as `releasable` allows) followed by - // however much of the new input is also releasable; two streaming calls into the wrapped - // decryptor, equivalent to one over their concatenation. - let from_tail = self.tail_len.min(releasable); - let from_new = releasable - from_tail; - if from_tail > 0 { - self.inner.do_update_out(&self.tail[..from_tail], &mut plaintext[..from_tail])?; - } - if from_new > 0 { - self.inner - .do_update_out(&ciphertext[..from_new], &mut plaintext[from_tail..releasable])?; - } - - // The new tail is whatever was not just released -- the suffix of the old tail, then the - // suffix of the new ciphertext -- which together are exactly TAG_LEN bytes, since - // `total - releasable == TAG_LEN` by construction of `releasable` above. - let mut new_tail = [0u8; TAG_LEN]; - let old_tail_kept = self.tail_len - from_tail; - new_tail[..old_tail_kept].copy_from_slice(&self.tail[from_tail..self.tail_len]); - new_tail[old_tail_kept..].copy_from_slice(&ciphertext[from_new..]); - self.tail = new_tail; - self.tail_len = TAG_LEN; - - Ok(releasable) - } - - /// Nothing is held back for release -- every plaintext byte was already emitted by - /// `do_update_out` -- so this is purely the tag check, against whatever ended up in `tail`. - /// The returned array is `FINAL_LEN = TAG_LEN` bytes only to match - /// [`TaggedEncryptor`]'s `FINAL_LEN` (the pair contract both traits share); the `0` data-byte - /// count says none of it is meaningful, exactly the case [`SimpleCipherDecryptor::do_final`]'s - /// own docs anticipate ("an authenticated cipher may release nothing at all once it has - /// checked the tag"). - /// - /// # Errors - /// [`SymmetricCipherError::DecryptionFailed`] if fewer than `TAG_LEN` bytes were ever seen (the - /// input was shorter than the tag). Otherwise, whatever - /// [`AEADCipherDecryptor::do_decrypt_final`] - /// returns, most notably [`SymmetricCipherError::AEADTagCheckFailed`]. - fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { - if self.tail_len < TAG_LEN { - return Err(SymmetricCipherError::DecryptionFailed); - } - let mut nothing = [0u8; 0]; - self.inner.do_decrypt_final(&self.tail, &mut nothing)?; - Ok(([0u8; TAG_LEN], 0)) - } - - /// The ciphertext length minus the tag, floored at `0` for an input shorter than the tag - /// (which `do_final` rejects rather than `do_update_out`, so the buffer must still be sized). - fn decrypt_out_max_len(ciphertext_len: usize) -> usize { - ciphertext_len.saturating_sub(TAG_LEN) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::key_material::{KeyMaterialTrait, KeyType, do_hazardous_operations}; - use crate::traits::RNG; - use bouncycastle_utils::secret::Secret; - - const KEY_LEN: usize = 4; - const NONCE_LEN: usize = 4; - const TAG_LEN: usize = 3; - - /// A toy AEAD: "ciphertext" is the plaintext XORed byte-by-byte with the key (cycled), and the - /// "tag" is a running XOR of every AAD/plaintext byte seen, repeated to `TAG_LEN` bytes. Not - /// remotely secure -- it exists only to drive `TaggedEncryptor`/`TaggedDecryptor` through - /// [`crate::traits::SimpleCipherEncryptor`]/[`SimpleCipherDecryptor`]'s chunked-equivalence - /// contract at exact byte-boundary edge cases around `TAG_LEN`, which is what this module's - /// hand-written tail bookkeeping needs pinned directly (see CLAUDE.md on testing - /// behaviour-critical private logic in-file). - #[derive(Clone)] - struct Toy { - key: Secret<[u8; KEY_LEN]>, - pos: usize, - acc: u8, - } - - impl Toy { - fn new(key: &KeyMaterial) -> Result { - let mut k = Secret::<[u8; KEY_LEN]>::new(); - k.copy_from_slice(key.ref_to_bytes()); - Ok(Self { key: k, pos: 0, acc: 0 }) - } - - /// Transforms `data` in place, accumulating `acc` over the *plaintext* byte on both - /// sides: encrypting, `data` starts as plaintext, so `acc` is updated before the XOR; - /// decrypting, `data` starts as ciphertext, so the XOR (which recovers the plaintext byte - /// into the same slot) must happen first. - fn transform(&mut self, data: &mut [u8], encrypting: bool) { - for b in data.iter_mut() { - if encrypting { - self.acc ^= *b; - } - *b ^= self.key[self.pos % KEY_LEN]; - if !encrypting { - self.acc ^= *b; - } - self.pos += 1; - } - } - } - - struct ToyEnc(Toy); - struct ToyDec(Toy); - - impl Algorithm for ToyEnc { - const ALG_NAME: &'static str = "toy-aead"; - const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; - } - impl Algorithm for ToyDec { - const ALG_NAME: &'static str = "toy-aead"; - const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; - } - - impl AEADCipherEncryptor for ToyEnc { - fn do_encrypt_init( - key: &KeyMaterial, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { - Ok((Self(Toy::new(key)?), [0u8; NONCE_LEN])) - } - fn do_encrypt_init_rng( - key: &KeyMaterial, - _rng: &mut dyn RNG, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { - Self::do_encrypt_init(key) - } - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - for &b in aad { - self.0.acc ^= b; - } - Ok(()) - } - fn update_out_len(&self, input_len: usize) -> usize { - input_len - } - fn do_update_out( - &mut self, - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result { - if ciphertext.len() < plaintext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "ciphertext", - plaintext.len(), - )); - } - let out = &mut ciphertext[..plaintext.len()]; - out.copy_from_slice(plaintext); - self.0.transform(out, true); - Ok(plaintext.len()) - } - fn do_encrypt_final( - self, - _output: &mut [u8; 0], - ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { - Ok((0, [self.0.acc; TAG_LEN])) - } - } - - impl AEADCipherDecryptor for ToyDec { - fn do_decrypt_init( - key: &KeyMaterial, - _nonce: &[u8; NONCE_LEN], - ) -> Result { - Ok(Self(Toy::new(key)?)) - } - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - for &b in aad { - self.0.acc ^= b; - } - Ok(()) - } - fn update_out_len(&self, input_len: usize) -> usize { - input_len - } - fn do_update_out( - &mut self, - ciphertext: &[u8], - plaintext: &mut [u8], - ) -> Result { - if plaintext.len() < ciphertext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "plaintext", - ciphertext.len(), - )); - } - let out = &mut plaintext[..ciphertext.len()]; - out.copy_from_slice(ciphertext); - self.0.transform(out, false); - Ok(ciphertext.len()) - } - fn do_decrypt_final( - self, - tag: &[u8; TAG_LEN], - _output: &mut [u8; 0], - ) -> Result { - if [self.0.acc; TAG_LEN] != *tag { - return Err(SymmetricCipherError::AEADTagCheckFailed); - } - Ok(0) - } - } - - fn key() -> KeyMaterial { - let mut km = - KeyMaterial::::from_bytes_as_type(&[1, 2, 3, 4], KeyType::SymmetricCipherKey) - .unwrap(); - do_hazardous_operations(&mut km, |k| { - k.set_key_type(KeyType::SymmetricCipherKey)?; - k.set_security_strength(SecurityStrength::None) - }) - .unwrap(); - km - } - - /// The one-shot round trip through the adapters, at every message length crossing a few - /// multiples of `TAG_LEN`, and every chunking of `do_update_out` on both sides -- this is what - /// pins the tail bookkeeping's off-by-one edges directly, complementing the framework's own - /// generic `test_encryptor_decryptor` coverage (which this same adapter pair is expected to - /// pass against `SimpleCipherEncryptor`/`SimpleCipherDecryptor`'s contract elsewhere). - #[test] - fn tagged_round_trip_at_every_length_and_chunking() { - let km = key(); - for len in 0..=(4 * TAG_LEN + 5) { - let msg: Vec = - (0..len).map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)).collect(); - - let (mut enc, nonce) = as SimpleCipherEncryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_encrypt_init(&km) - .unwrap(); - enc.do_update_aad::(b"aad").unwrap(); - let mut ct = vec![0u8; msg.len() + TAG_LEN]; - for chunk in [1usize, 2, 3, TAG_LEN.max(1), len.max(1)] { - let mut enc = { - let (mut e, _) = as SimpleCipherEncryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_encrypt_init(&km) - .unwrap(); - e.do_update_aad::(b"aad").unwrap(); - e - }; - let mut written = 0; - for piece in msg.chunks(chunk) { - written += enc.do_update_out(piece, &mut ct[written..]).unwrap(); - } - let mut last = [0u8; TAG_LEN]; - let last_len = as SimpleCipherEncryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_final_out(enc, &mut last) - .unwrap(); - ct[written..written + last_len].copy_from_slice(&last[..last_len]); - written += last_len; - ct.truncate(written); - - let mut dec = as SimpleCipherDecryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_decrypt_init(&km, &nonce) - .unwrap(); - dec.do_update_aad::(b"aad").unwrap(); - let mut pt = vec![0u8; ct.len()]; - let mut written = 0; - for piece in ct.chunks(chunk) { - written += dec.do_update_out(piece, &mut pt[written..]).unwrap(); - } - let (_, data_len) = dec.do_final().unwrap(); - pt.truncate(written + data_len); - assert_eq!(pt, msg, "len {len}, chunk {chunk}"); - - ct.resize(msg.len() + TAG_LEN, 0); - } - } - } - - /// A tampered inline stream must fail at `do_final`, and a stream shorter than the tag must be - /// rejected as `DecryptionFailed` rather than panicking on the short slice. - #[test] - fn tampering_and_short_input_are_rejected() { - let km = key(); - let (mut enc, nonce) = as SimpleCipherEncryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_encrypt_init(&km) - .unwrap(); - let mut ct = vec![0u8; 10 + TAG_LEN]; - let written = enc.do_update_out(&[7u8; 10], &mut ct).unwrap(); - let mut last = [0u8; TAG_LEN]; - let last_len = as SimpleCipherEncryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_final_out(enc, &mut last) - .unwrap(); - ct[written..written + last_len].copy_from_slice(&last[..last_len]); - - let mut tampered = ct.clone(); - tampered[0] ^= 0xFF; - let mut dec = as SimpleCipherDecryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_decrypt_init(&km, &nonce) - .unwrap(); - let mut pt = vec![0u8; tampered.len()]; - let mut written = 0; - written += dec.do_update_out(&tampered, &mut pt[written..]).unwrap(); - let _ = written; - assert!(matches!(dec.do_final(), Err(SymmetricCipherError::AEADTagCheckFailed))); - - for short_len in 0..TAG_LEN { - let dec = as SimpleCipherDecryptor< - KEY_LEN, - NONCE_LEN, - TAG_LEN, - >>::do_decrypt_init(&km, &nonce) - .unwrap(); - let mut dec = dec; - let mut pt = vec![0u8; short_len]; - dec.do_update_out(&ct[..short_len], &mut pt).unwrap(); - assert!(matches!(dec.do_final(), Err(SymmetricCipherError::DecryptionFailed))); - } - } -} diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index d54cdc45..b67173d6 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -12,6 +12,13 @@ use crate::key_material::KeyMaterial; use crate::key_material::KeyType; // end of imports needed for docs +/// What the allocating one-shot [`AEADCipherEncryptor::encrypt`] hands back: the nonce it +/// generated, the ciphertext, and the tag, in that order. A named type because the bare triple is +/// past what is readable inline (clippy's `type_complexity`). +#[cfg(feature = "std")] +pub type AEADEncrypted = + ([u8; NONCE_LEN], Vec, [u8; TAG_LEN]); + /// The decryption half of an AEAD cipher's streaming API; see [`AEADCipherEncryptor`], whose notes /// on the AAD phase, buffering, and the `Result` all apply here too. /// @@ -96,6 +103,49 @@ pub trait AEADCipherDecryptor< output: &mut [u8; FINAL_LEN], ) -> Result; + /// Streaming finalization for the inline `ciphertext || tag` layout: `tail` is the end of the + /// ciphertext stream -- whatever ciphertext has not been given to + /// [`do_update_out`](Self::do_update_out) yet, followed by the `TAG_LEN` tag bytes. The + /// ciphertext part is decrypted into `plaintext`, and the trailing bytes are then checked as + /// the tag, exactly as [`do_decrypt_final`](Self::do_decrypt_final) checks one handed to it + /// separately. Returns the number of plaintext bytes written here. + /// + /// The tag is only identifiable once the stream ends, so a caller streaming this layout has to + /// hold back the last `TAG_LEN` bytes it has seen at all times and pass them in here; nothing + /// earlier in the stream can tell it which bytes they will be. + /// + /// `plaintext` needs `update_out_len(tail.len() - TAG_LEN) + FINAL_LEN` bytes. As with + /// [`do_update_out`](Self::do_update_out), nothing written here is authenticated until the + /// call returns `Ok`. + /// + /// # Errors + /// [`SymmetricCipherError::DecryptionFailed`] if `tail` is shorter than `TAG_LEN`, i.e. the + /// stream ended before a whole tag had been seen; + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `plaintext` is too short, checked + /// before any work is done; [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not + /// verify. + fn tagged_do_aead_decrypt_final( + mut self, + tail: &[u8], + plaintext: &mut [u8], + ) -> Result { + if tail.len() < TAG_LEN { + return Err(SymmetricCipherError::DecryptionFailed); + } + let (ciphertext, tag) = tail.split_at(tail.len() - TAG_LEN); + let needed = self.update_out_len(ciphertext.len()) + FINAL_LEN; + if plaintext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("plaintext", needed)); + } + // infallible: `split_at` above leaves exactly `TAG_LEN` bytes in `tag`. + let tag: &[u8; TAG_LEN] = tag.try_into().unwrap(); + let written = self.do_update_out(ciphertext, plaintext)?; + let mut final_buf = [0u8; FINAL_LEN]; + let final_len = self.do_decrypt_final(tag, &mut final_buf)?; + plaintext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); + Ok(written + final_len) + } + /// An upper bound on the plaintext recovered from `ciphertext_len` bytes of ciphertext, i.e. /// the buffer [`decrypt_out`](Self::decrypt_out) requires. The default returns `ciphertext_len` /// itself, which is exact for every conformant AEAD: unlike a padding scheme, an AEAD never @@ -134,6 +184,8 @@ pub trait AEADCipherDecryptor< let mut final_buf = [0u8; FINAL_LEN]; match dec.do_decrypt_final(tag, &mut final_buf) { Ok(final_len) => { + // Implementors with FINAL_LEN > 0 must override `decrypt_out_max_len` so this fits + // in `plaintext[..needed]`. plaintext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok(written + final_len) } @@ -150,6 +202,37 @@ pub trait AEADCipherDecryptor< } } + /// The plaintext buffer [`tagged_decrypt`](Self::tagged_decrypt) requires for `ciphertext_len` + /// bytes of `ciphertext || tag`: what the ciphertext alone needs, the tag being no part of the + /// plaintext. + fn tagged_decrypt_out_max_len(ciphertext_len: usize) -> usize { + Self::decrypt_out_max_len(ciphertext_len.saturating_sub(TAG_LEN)) + } + + /// One-shot over the inline `ciphertext || tag` layout: takes the trailing `TAG_LEN` bytes of + /// `ciphertext` as the tag, and is otherwise exactly [`decrypt_out`](Self::decrypt_out), + /// including zeroizing `plaintext` when the tag does not verify. `plaintext` needs + /// [`tagged_decrypt_out_max_len`](Self::tagged_decrypt_out_max_len) bytes. + /// + /// # Errors + /// [`SymmetricCipherError::DecryptionFailed`] if `ciphertext` is shorter than the tag it is + /// supposed to end with; otherwise as [`decrypt_out`](Self::decrypt_out). + fn tagged_decrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + if ciphertext.len() < TAG_LEN { + return Err(SymmetricCipherError::DecryptionFailed); + } + let (body, tag) = ciphertext.split_at(ciphertext.len() - TAG_LEN); + // infallible: `split_at` above leaves exactly `TAG_LEN` bytes in `tag`. + let tag: &[u8; TAG_LEN] = tag.try_into().unwrap(); + Self::decrypt_out(key, nonce, aad, body, tag, plaintext) + } + #[cfg(feature = "std")] /// One-shot, allocating: as [`decrypt_out`](Self::decrypt_out), returning the plaintext as a /// `Vec` of exactly the recovered length. Only available with the `std` feature. @@ -186,8 +269,10 @@ pub trait AEADCipherDecryptor< /// consumes the encryptor, flushes whatever ciphertext it was holding back into `output`, and /// returns the tag, which the recipient needs for [`AEADCipherDecryptor::do_decrypt_final`]. Where /// the tag travels -- appended to the ciphertext, carried in a separate field -- is the caller's -/// choice, not this trait's; see `bouncycastle_core::tagged_aead` for an adapter that appends it -/// to the ciphertext. +/// choice, not this trait's, which is why the inline layout has its own entry points +/// ([`tagged_encrypt`](Self::tagged_encrypt), +/// [`tagged_do_aead_encrypt_final`](Self::tagged_do_aead_encrypt_final)) rather than being the +/// only thing on offer. /// /// Encryption and decryption are separate traits, as with [`BlockCipherEncryptor`] / /// [`BlockCipherDecryptor`], so that the direction is encoded in the type. For an AEAD that also @@ -214,7 +299,9 @@ pub trait AEADCipherDecryptor< /// Ascon-AEAD128 does -- each rate-block byte is transformed independently of the others in that /// block -- but a block-oriented AEAD holds back a partial final block, and any AEAD adapted to an /// inline `ciphertext || tag` layout must hold back at least `TAG_LEN` bytes until it knows they -/// are not the tag (see `bouncycastle_core::tagged_aead`). [`update_out_len`](Self::update_out_len) +/// are not the tag (which is what a caller of +/// [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`] does for itself). +/// [`update_out_len`](Self::update_out_len) /// answers exactly how many bytes the next call releases, so a caller never has to guess a buffer /// size or find plaintext left over at the end of one it guessed too large; the concatenation of /// everything released, in any chunking, plus the data part of @@ -299,6 +386,40 @@ pub trait AEADCipherEncryptor< output: &mut [u8; FINAL_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError>; + /// Streaming finalization for the inline `ciphertext || tag` layout: as + /// [`do_encrypt_final`](Self::do_encrypt_final), except that the tag is *appended* to whatever + /// ciphertext was held back rather than returned on its own, so what this writes into `output` + /// is simply the tail of the stream [`do_update_out`](Self::do_update_out) has been writing. + /// Returns the number of bytes written: the flushed ciphertext plus `TAG_LEN`. + /// + /// `output` needs `FINAL_LEN + TAG_LEN` bytes -- the full flush, even where less than that is + /// actually being held back, since how much that is cannot be known until the cipher is + /// finalized. A streaming caller sizing its output with + /// [`tagged_encrypt_out_len`](Self::tagged_encrypt_out_len) therefore has to allocate + /// `FINAL_LEN` more than that if it wants to write the whole stream into one buffer. + /// + /// A caller who wants the tag as a field of its own calls + /// [`do_encrypt_final`](Self::do_encrypt_final) instead; the decrypting counterpart of this + /// method is [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`]. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `output` is shorter than + /// `FINAL_LEN + TAG_LEN`, checked before the cipher is finalized. + fn tagged_do_aead_encrypt_final( + self, + output: &mut [u8], + ) -> Result { + let needed = FINAL_LEN + TAG_LEN; + if output.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("output", needed)); + } + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, tag) = self.do_encrypt_final(&mut final_buf)?; + output[..final_len].copy_from_slice(&final_buf[..final_len]); + output[final_len..final_len + TAG_LEN].copy_from_slice(&tag); + Ok(final_len + TAG_LEN) + } + /// The exact ciphertext length for a `plaintext_len`-byte plaintext, i.e. the buffer /// [`encrypt_out`](Self::encrypt_out) requires and the number of bytes it writes (the tag is /// returned separately, not counted here). The default returns `plaintext_len` itself, which @@ -356,10 +477,42 @@ pub trait AEADCipherEncryptor< let written = enc.do_update_out(plaintext, ciphertext)?; let mut final_buf = [0u8; FINAL_LEN]; let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; + // As in `encrypt_out`: an implementor with FINAL_LEN > 0 must override `encrypt_out_len` + // so this fits in `ciphertext[..needed]`. ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok((nonce, written + final_len, tag)) } + /// The ciphertext buffer [`tagged_encrypt`](Self::tagged_encrypt) requires: what the + /// separate-tag [`encrypt_out`](Self::encrypt_out) needs, plus the `TAG_LEN` bytes appended to + /// it. + fn tagged_encrypt_out_len(plaintext_len: usize) -> usize { + Self::encrypt_out_len(plaintext_len) + TAG_LEN + } + + /// One-shot into the inline `ciphertext || tag` layout: as [`encrypt_out`](Self::encrypt_out), + /// except that the tag is appended to `ciphertext` instead of being returned separately. + /// `ciphertext` needs [`tagged_encrypt_out_len`](Self::tagged_encrypt_out_len) bytes. Returns + /// the generated nonce and the total number of bytes written, tag included. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `ciphertext` is too short, checked + /// before any work is done; otherwise as [`encrypt_out`](Self::encrypt_out). + fn tagged_encrypt( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError> { + let needed = Self::tagged_encrypt_out_len(plaintext.len()); + if ciphertext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("ciphertext", needed)); + } + let (nonce, written, tag) = Self::encrypt_out(key, aad, plaintext, ciphertext)?; + ciphertext[written..written + TAG_LEN].copy_from_slice(&tag); + Ok((nonce, written + TAG_LEN)) + } + #[cfg(feature = "std")] /// One-shot, allocating: as [`encrypt_out`](Self::encrypt_out), returning the ciphertext as a /// `Vec`. Only available with the `std` feature. @@ -367,7 +520,7 @@ pub trait AEADCipherEncryptor< key: &KeyMaterial, aad: &[u8], plaintext: &[u8], - ) -> Result<([u8; NONCE_LEN], Vec, [u8; TAG_LEN]), SymmetricCipherError> { + ) -> Result, SymmetricCipherError> { let mut ciphertext = vec![0u8; Self::encrypt_out_len(plaintext.len())]; let (nonce, written, tag) = Self::encrypt_out(key, aad, plaintext, &mut ciphertext)?; ciphertext.truncate(written); diff --git a/crypto/core/tests/aead_tagged_tests.rs b/crypto/core/tests/aead_tagged_tests.rs new file mode 100644 index 00000000..42ce29c1 --- /dev/null +++ b/crypto/core/tests/aead_tagged_tests.rs @@ -0,0 +1,303 @@ +//! Integration tests for the inline `ciphertext || tag` layout on +//! [`AEADCipherEncryptor`]/[`AEADCipherDecryptor`] -- `tagged_encrypt`, +//! `tagged_do_aead_encrypt_final`, `tagged_decrypt` and `tagged_do_aead_decrypt_final` -- driven +//! over a toy AEAD, which is what lets the length and tag-placement edges be checked exactly. + +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, +}; +use bouncycastle_utils::secret::Secret; + +const KEY_LEN: usize = 4; +const NONCE_LEN: usize = 4; +const TAG_LEN: usize = 3; + +/// A toy AEAD: "ciphertext" is the plaintext XORed byte-by-byte with the key (cycled), and the +/// "tag" is a running XOR of every AAD/plaintext byte seen, repeated to `TAG_LEN` bytes. Not +/// remotely secure -- it exists only to drive the `tagged_*` defaults at exact byte-boundary edge +/// cases around `TAG_LEN`, with a `TAG_LEN` small enough (3) that "the tag is the last few bytes" +/// and "the message is shorter than the tag" are both cheap to enumerate. +#[derive(Clone)] +struct Toy { + key: Secret<[u8; KEY_LEN]>, + pos: usize, + acc: u8, +} + +impl Toy { + fn new(key: &KeyMaterial) -> Result { + let mut k = Secret::<[u8; KEY_LEN]>::new(); + k.copy_from_slice(key.ref_to_bytes()); + Ok(Self { key: k, pos: 0, acc: 0 }) + } + + /// Transforms `data` in place, accumulating `acc` over the *plaintext* byte on both + /// sides: encrypting, `data` starts as plaintext, so `acc` is updated before the XOR; + /// decrypting, `data` starts as ciphertext, so the XOR (which recovers the plaintext byte + /// into the same slot) must happen first. + fn transform(&mut self, data: &mut [u8], encrypting: bool) { + for b in data.iter_mut() { + if encrypting { + self.acc ^= *b; + } + *b ^= self.key[self.pos % KEY_LEN]; + if !encrypting { + self.acc ^= *b; + } + self.pos += 1; + } + } +} + +struct ToyEnc(Toy); +struct ToyDec(Toy); + +impl Algorithm for ToyEnc { + const ALG_NAME: &'static str = "toy-aead"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; +} +impl Algorithm for ToyDec { + const ALG_NAME: &'static str = "toy-aead"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; +} + +impl AEADCipherEncryptor for ToyEnc { + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Ok((Self(Toy::new(key)?), [0u8; NONCE_LEN])) + } + fn do_encrypt_init_rng( + key: &KeyMaterial, + _rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + Self::do_encrypt_init(key) + } + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.0.acc ^= b; + } + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + fn do_update_out( + &mut self, + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + if ciphertext.len() < plaintext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "ciphertext", + plaintext.len(), + )); + } + let out = &mut ciphertext[..plaintext.len()]; + out.copy_from_slice(plaintext); + self.0.transform(out, true); + Ok(plaintext.len()) + } + fn do_encrypt_final( + self, + _output: &mut [u8; 0], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + Ok((0, [self.0.acc; TAG_LEN])) + } +} + +impl AEADCipherDecryptor for ToyDec { + fn do_decrypt_init( + key: &KeyMaterial, + _nonce: &[u8; NONCE_LEN], + ) -> Result { + Ok(Self(Toy::new(key)?)) + } + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.0.acc ^= b; + } + Ok(()) + } + fn update_out_len(&self, input_len: usize) -> usize { + input_len + } + fn do_update_out( + &mut self, + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + if plaintext.len() < ciphertext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "plaintext", + ciphertext.len(), + )); + } + let out = &mut plaintext[..ciphertext.len()]; + out.copy_from_slice(ciphertext); + self.0.transform(out, false); + Ok(ciphertext.len()) + } + fn do_decrypt_final( + self, + tag: &[u8; TAG_LEN], + _output: &mut [u8; 0], + ) -> Result { + if [self.0.acc; TAG_LEN] != *tag { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok(0) + } +} + +fn key() -> KeyMaterial { + let mut km = + KeyMaterial::::from_bytes_as_type(&[1, 2, 3, 4], KeyType::SymmetricCipherKey) + .unwrap(); + do_hazardous_operations(&mut km, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::None) + }) + .unwrap(); + km +} + +const AAD: &[u8] = b"aad"; + +/// Encrypts `msg` into the inline layout with the one-shot, and returns it. +fn tagged_ct(km: &KeyMaterial, msg: &[u8]) -> (Vec, [u8; NONCE_LEN]) { + let mut ct = vec![0u8; ToyEnc::tagged_encrypt_out_len(msg.len())]; + let (nonce, written) = ToyEnc::tagged_encrypt(km, AAD, msg, &mut ct).unwrap(); + assert_eq!(written, msg.len() + TAG_LEN, "inline layout is ciphertext || tag"); + ct.truncate(written); + (ct, nonce) +} + +/// The one-shot pair round-trips at every length crossing a few multiples of `TAG_LEN`, and the +/// streaming pair agrees with it for every chunking -- the caller holding back the last `TAG_LEN` +/// bytes itself, as `tagged_do_aead_decrypt_final`'s docs require. +#[test] +fn tagged_round_trip_at_every_length_and_chunking() { + let km = key(); + for len in 0..=(4 * TAG_LEN + 5) { + let msg: Vec = (0..len).map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)).collect(); + let (ct, nonce) = tagged_ct(&km, &msg); + + let mut pt = vec![0u8; ToyDec::tagged_decrypt_out_max_len(ct.len())]; + let n = ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct, &mut pt).unwrap(); + assert_eq!(&pt[..n], &msg[..], "len {len}: one-shot round trip"); + + for chunk in [1usize, 2, 3, TAG_LEN.max(1), len.max(1)] { + // Encrypt in chunks, finishing with the tag appended by the streaming finalizer. + let (mut enc, stream_nonce) = ToyEnc::do_encrypt_init(&km).unwrap(); + enc.do_update_aad(AAD).unwrap(); + let mut stream_ct = vec![0u8; msg.len() + TAG_LEN]; + let mut written = 0; + for piece in msg.chunks(chunk) { + written += enc.do_update_out(piece, &mut stream_ct[written..]).unwrap(); + } + written += enc.tagged_do_aead_encrypt_final(&mut stream_ct[written..]).unwrap(); + stream_ct.truncate(written); + assert_eq!( + stream_ct, ct, + "len {len}, chunk {chunk}: streaming must match the one-shot" + ); + + // Decrypt in chunks, holding back the last TAG_LEN bytes for the finalizer. + let mut dec = ToyDec::do_decrypt_init(&km, &stream_nonce).unwrap(); + dec.do_update_aad(AAD).unwrap(); + let body_len = stream_ct.len() - TAG_LEN; + let mut out = vec![0u8; stream_ct.len()]; + let mut written = 0; + for piece in stream_ct[..body_len].chunks(chunk) { + written += dec.do_update_out(piece, &mut out[written..]).unwrap(); + } + written += dec + .tagged_do_aead_decrypt_final(&stream_ct[body_len..], &mut out[written..]) + .unwrap(); + out.truncate(written); + assert_eq!(out, msg, "len {len}, chunk {chunk}: streaming round trip"); + } + } +} + +/// A tampered inline stream fails at finalization on both entry points, and an input shorter than +/// the tag is rejected as `DecryptionFailed` rather than panicking on the short slice. +#[test] +fn tampering_and_short_input_are_rejected() { + let km = key(); + let msg = [7u8; 10]; + let (ct, nonce) = tagged_ct(&km, &msg); + + let mut tampered = ct.clone(); + tampered[0] ^= 0xFF; + let mut pt = vec![0u8; tampered.len()]; + assert!(matches!( + ToyDec::tagged_decrypt(&km, &nonce, AAD, &tampered, &mut pt), + Err(SymmetricCipherError::AEADTagCheckFailed) + )); + assert_eq!(pt, vec![0u8; tampered.len()], "the one-shot zeroizes on a failed tag check"); + + let mut dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); + dec.do_update_aad(AAD).unwrap(); + assert!(matches!( + dec.tagged_do_aead_decrypt_final(&tampered, &mut pt), + Err(SymmetricCipherError::AEADTagCheckFailed) + )); + + for short_len in 0..TAG_LEN { + let mut pt = vec![0u8; TAG_LEN]; + assert!(matches!( + ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct[..short_len], &mut pt), + Err(SymmetricCipherError::DecryptionFailed) + )); + let dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); + assert!(matches!( + dec.tagged_do_aead_decrypt_final(&ct[..short_len], &mut pt), + Err(SymmetricCipherError::DecryptionFailed) + )); + } +} + +/// Every `tagged_*` entry point refuses an output buffer that is one byte short, naming the length +/// it needs, and does so before touching the cipher. +#[test] +fn tagged_undersized_buffers_are_rejected() { + let km = key(); + let msg = [3u8; 8]; + let (ct, nonce) = tagged_ct(&km, &msg); + + let needed = ToyEnc::tagged_encrypt_out_len(msg.len()); + assert_eq!(needed, msg.len() + TAG_LEN); + let mut short = vec![0u8; needed - 1]; + match ToyEnc::tagged_encrypt(&km, AAD, &msg, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => assert_eq!(n, needed), + other => panic!("tagged_encrypt into a short buffer: {other:?}"), + } + + let (enc, _) = ToyEnc::do_encrypt_init(&km).unwrap(); + let mut short = [0u8; TAG_LEN - 1]; + match enc.tagged_do_aead_encrypt_final(&mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => assert_eq!(n, TAG_LEN), + other => panic!("tagged_do_aead_encrypt_final into a short buffer: {other:?}"), + } + + let needed = ToyDec::tagged_decrypt_out_max_len(ct.len()); + assert_eq!(needed, msg.len()); + let mut short = vec![0u8; needed - 1]; + match ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => assert_eq!(n, needed), + other => panic!("tagged_decrypt into a short buffer: {other:?}"), + } + + let dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); + let mut short = vec![0u8; msg.len() - 1]; + match dec.tagged_do_aead_decrypt_final(&ct, &mut short) { + Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => assert_eq!(n, msg.len()), + other => panic!("tagged_do_aead_decrypt_final into a short buffer: {other:?}"), + } +} From c8190be660668ef638fbd865065c11a611cfb96e Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 20 Sep 2026 19:48:10 +1000 Subject: [PATCH 11/16] release notes: re-measure bouncycastle-ascon after the AEAD trait changes (#119) 661 mutants, 558 caught, 97 unviable, 6 missed -- the same six known equivalences (the sponge absorb/squeeze boundaries and the two disjoint-bit `|` -> `^` in set_state_byte). The count moves from 655 with the new_encrypting/new_decrypting constructors. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- alpha_0.1.3_release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 4de4ab1e..3d08bd66 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -27,7 +27,7 @@ `tagged_do_aead_decrypt_final` take it back off the end of one. * ASCON testing covers the NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus embedded always-on - vectors. Mutation testing for `bouncycastle-ascon` reports 655 mutants, 558 caught, 91 unviable + vectors. Mutation testing for `bouncycastle-ascon` reports 661 mutants, 558 caught, 97 unviable and 6 missed; the six survivors are the sponge boundary and `set_state_byte` OR/XOR equivalences documented at their sites. From f376c14df4219df85dfe349112dcd9f3f5d5a569 Mon Sep 17 00:00:00 2001 From: David Hook Date: Mon, 21 Sep 2026 04:24:29 +1000 Subject: [PATCH 12/16] core, core-test-framework: close the mutation gaps a scoped run found in the tagged AEAD defaults (#119) `cargo mutants -p bouncycastle-core -f crypto/core/src/traits.rs --re 'AEADCipherEncryptor|AEADCipherDecryptor' --test-package bouncycastle-core --test-package bouncycastle-ascon` reported 116 mutants, 91 caught, 19 unviable, 6 missed. Four of the six were real: the buffer guards could be weakened without a test noticing, because a too-short buffer is rejected either by the guard or by the `do_update_out` behind it, and both report IncorrectOutputBufferLength with the same length -- so the probes could not tell which had fired. - crypto/core/tests/aead_tagged_tests.rs: `tagged_do_aead_decrypt_final` with a buffer of exactly `needed` must succeed. Kills `plaintext.len() < needed` -> `<=` and -> `==`. - crypto/core-test-framework: the buffering toy now finishes from a tail that still holds ciphertext (TAG_LEN + 4 bytes) into an exactly-sized buffer, which is what makes `update_out_len(..) + FINAL_LEN` observable -- with a generous buffer any arithmetic there would do. Kills `+ FINAL_LEN` -> `* FINAL_LEN`. The AEAD suite also feeds `encrypt_out_rng` a buffer with room to spare, so its own guard cannot be flipped to `>` unnoticed. The re-run is 116 mutants, 95 caught, 19 unviable, 2 missed; the two are `written + final_len` -> `written - final_len` in `encrypt_out_rng`, equivalent while every implementor has FINAL_LEN = 0. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- .../src/symmetric_ciphers.rs | 26 ++++++++++++++++--- crypto/core/tests/aead_tagged_tests.rs | 10 +++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 37c9c8ee..b48ef1ef 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -607,6 +607,19 @@ impl TestFrameworkAEADCipher { } other => panic!("encrypt_out_rng into a short buffer: {other:?}"), } + // ...and one with room to spare must be accepted: without this the guard can be + // flipped to `>` and every short-buffer probe still "passes", because the error + // then comes from `do_update_out` behind it with the same variant and length. + let mut roomy = vec![0u8; need + 3]; + let (_, n, _) = E::encrypt_out_rng( + &key, + &mut FixedSeedRNG::::new([0xA5u8; NONCE_LEN]), + aad, + msg, + &mut roomy, + ) + .unwrap(); + assert_eq!(n, need, "encrypt_out_rng must write exactly encrypt_out_len bytes"); } let need = D::decrypt_out_max_len(ct.len()); if need > 0 { @@ -1092,12 +1105,19 @@ impl TestFrameworkAEADCipher { "len {len}: inline layout is the message plus a tag" ); - let body = written - TAG_LEN; + // Stop a few bytes short of the tag as well, so the finalizer has real ciphertext to + // decrypt and not just a tag to check, and give it a buffer of exactly the length it + // asks for: that is what makes `update_out_len(..) + FINAL_LEN` observable, since with + // a generous buffer any arithmetic there would do. + let held_back = (TAG_LEN + 4).min(written); + let body = written - held_back; let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); let mut pt = vec![0u8; written + HOLD_BACK]; let mut got = dec.do_update_out(&inline[..body], &mut pt).unwrap(); - got += - dec.tagged_do_aead_decrypt_final(&inline[body..written], &mut pt[got..]).unwrap(); + let need = dec.update_out_len(held_back - TAG_LEN) + HOLD_BACK; + got += dec + .tagged_do_aead_decrypt_final(&inline[body..written], &mut pt[got..got + need]) + .unwrap(); assert_eq!(&pt[..got], msg, "len {len}: inline streaming round trip"); let mut one = vec![0u8; Enc::tagged_encrypt_out_len(len)]; diff --git a/crypto/core/tests/aead_tagged_tests.rs b/crypto/core/tests/aead_tagged_tests.rs index 42ce29c1..9517441a 100644 --- a/crypto/core/tests/aead_tagged_tests.rs +++ b/crypto/core/tests/aead_tagged_tests.rs @@ -300,4 +300,14 @@ fn tagged_undersized_buffers_are_rejected() { Err(SymmetricCipherError::IncorrectOutputBufferLength(_, n)) => assert_eq!(n, msg.len()), other => panic!("tagged_do_aead_decrypt_final into a short buffer: {other:?}"), } + + // A buffer of exactly the length it asks for must be accepted. Without this the + // `plaintext.len() < needed` guard can be weakened to `<=` or `==` without any test noticing: + // a too-short buffer is caught either way, by the guard or by `do_update_out` behind it, and + // both report the same error with the same length. + let mut dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); + dec.do_update_aad(AAD).unwrap(); + let mut exact = vec![0u8; msg.len()]; + let n = dec.tagged_do_aead_decrypt_final(&ct, &mut exact).unwrap(); + assert_eq!(&exact[..n], &msg[..], "a buffer of exactly `needed` bytes must be enough"); } From a1468a91960bbf6a54a555d3f078e907f684e417 Mon Sep 17 00:00:00 2001 From: David Hook Date: Mon, 21 Sep 2026 08:26:58 +1000 Subject: [PATCH 13/16] release notes: record the scoped mutation figures for the AEAD trait defaults (#119) The ascon crate's numbers were already there; the pair's own defaults in core were only in f376c14's commit message. 116 mutants, 95 caught, 19 unviable, 2 missed. Assisted-by: Claude:claude-opus-5 Co-Authored-By: Claude Opus 5 --- alpha_0.1.3_release_notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 3d08bd66..528f8de1 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -21,6 +21,10 @@ The older single-type `core::traits::AEADCipher`, which this splits and which had no implementors, is removed, along with its `core-test-framework` suites (`TestFrameworkAEADCipher::test` / `::test_plain_one_shots`). + Mutation testing of the pair's defaults (`traits.rs`, scoped to `AEADCipher{En,De}cryptor` and + tested through `bouncycastle-core` + `bouncycastle-ascon`) reports 116 mutants, 95 caught, 19 + unviable and 2 missed, the two being `written + final_len` -> `written - final_len` in + `encrypt_out_rng`, equivalent while every implementor has `FINAL_LEN = 0`. * The same pair carries the inline `ciphertext || tag` layout that most wire formats and files use, as four default methods rather than a separate adapter type: `tagged_encrypt` / `tagged_do_aead_encrypt_final` append the tag to the ciphertext stream, and `tagged_decrypt` / From 62e6a2bf017f6e8e1347b4b21d704c24fef2796d Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 21 Sep 2026 15:52:15 +0700 Subject: [PATCH 14/16] docs: fix contributing typos Assisted-by: Claude:claude-sonnet-5 --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f250d37..cf6575c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,10 +43,10 @@ Some specifics: * Public APIs of a library should be both ergonomic and expressive. When defining a new trait or public function, ask yourself whether a programmer who is new to cryptography is likely to use this in a way that will get them into trouble. -* Variables should be well-named, well-structured, and well-commented (a comment-to-code ration of 1:1 is a goal to be +* Variables should be well-named, well-structured, and well-commented (a comment-to-code ratio of 1:1 is a goal to be strived for!). Think about memory footprint and, where possible, use unnamed scopes to allow the compiler to pop intermediate value variables off the stack as soon as they are no longer needed. -* Always run your code through `cargo mutants` and get the issue count as low as your can. As a first pass, this forces +* Always run your code through `cargo mutants` and get the issue count as low as you can. As a first pass, this forces you to write thorough unit tests. As a second pass, this draws your attention to bits of your code that cannot be tested from the outside. Often this means that the code can be simplified without affecting functionality (as defined by your set of unit tests) -- "simpler code" usually means faster runtime and easier future maintenance. @@ -71,7 +71,7 @@ For minor updates, you can instead choose to create an issue with short snippets * For contributions touching multiple files try and split up the pull request, smaller changes are easier to review and test, as well as being less likely to run into merge issues. -* Create a test cases for your change, it may be a simple addition to an existing test. If you do not know how to do +* Create test cases for your change; it may be a simple addition to an existing test. If you do not know how to do this, ask us and we will help you. * If you run into any merge issues, check out this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues. From 336f9cb93caaebec2bee9d6839eab048f0f8702b Mon Sep 17 00:00:00 2001 From: David Hook Date: Thu, 24 Sep 2026 10:43:47 +1000 Subject: [PATCH 15/16] core, core-test-framework, ascon, cli: make AEADCipherEncryptor/AEADCipherDecryptor extend SymmetricCipherEncryptor/SymmetricCipherDecryptor, with the detached-tag methods named *_detached and the inline one-shots with AAD named *_with_aad The inherited SymmetricCipher{En,De}cryptor methods are the AEAD with no associated data and the tag inline (ciphertext || tag), so an AEAD can be held and used as a plain symmetric cipher. FINAL_LEN keeps one meaning across both traits: the tag plus anything the cipher holds back. Every AEAD decryptor now holds back the last TAG_LEN bytes it has seen, since do_update_out is shared and cannot know which final will be called: SymmetricCipherDecryptor::do_final checks those bytes as the tag, AEADCipherDecryptor::do_final_out_detached decrypts them as ciphertext. The AEAD traits keep do_update_aad and add, named for the base method they mirror: do_final_detached / do_final_out_detached (the _out form is the required one), encrypt_out_detached, encrypt_out_rng_detached, encrypt_detached, decrypt_out_detached, decrypt_detached and the *_len_detached sizing helpers; and encrypt_out_with_aad, encrypt_out_rng_with_aad, encrypt_with_aad, decrypt_out_with_aad and decrypt_with_aad for the inline layout with AAD. do_encrypt_init, do_decrypt_init, update_out_len and do_update_out are now inherited, and tagged_do_aead_{en,de}crypt_final and the tagged_*_len helpers are gone, their jobs taken by the inherited do_final and length helpers. SymmetricCipherDecryptor::decrypt_out now zeroizes what it wrote when do_final fails, since an AEAD reaches it through this trait and the AEAD one-shots always have. AsconAead128Encryptor / AsconAead128Decryptor implement both layers with FINAL_LEN = TAG_LEN; the decryptor carries the 16-byte hold-back. The CLI's Ascon-AEAD128 decrypt stream drops its hand-rolled tag tail, which the decryptor now does itself. TestFrameworkAEADCipher::test_encryptor_decryptor runs the whole TestFrameworkSymmetricCipher suite first, then the AEAD checks; the buffering toy implements both layers and drives every one-shot. cargo mutants -p bouncycastle-core -f crypto/core/src/traits.rs --re 'AEADCipher|SymmetricCipherDecryptor::decrypt_out' --test-package bouncycastle-core --test-package bouncycastle-ascon: 134 mutants, 107 caught, 27 unviable, 0 missed. cargo mutants -p bouncycastle-ascon -f crypto/ascon/src/ascon_aead128.rs --re 'AsconAead128(En|De)cryptor' --test-package bouncycastle-ascon --test-package cli: 76 mutants, 49 caught, 27 unviable, 0 missed. Assisted-by: Claude:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- alpha_0.1.3_release_notes.md | 27 +- cli/src/ascon_cmd.rs | 69 +- crypto/ascon/src/ascon_aead128.rs | 147 +++- crypto/ascon/src/lib.rs | 57 +- crypto/ascon/tests/aead128_tests.rs | 100 ++- .../src/symmetric_ciphers.rs | 713 +++++++++++------- crypto/core/src/traits.rs | 583 +++++++------- crypto/core/tests/aead_tagged_tests.rs | 226 +++--- 8 files changed, 1133 insertions(+), 789 deletions(-) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 528f8de1..617f493a 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -16,19 +16,28 @@ Streaming decrypt releases plaintext before the final tag check, so callers must discard any output if finalization or the CLI exit status reports authentication failure. * `core` gains the streaming AEAD split: `AEADCipherEncryptor` and `AEADCipherDecryptor<...>`, with AAD updates, exact `update_out_len`, detached - tags, one-shot helpers and a `FINAL_LEN` flush buffer for implementations that hold data back. + FINAL_LEN>` and `AEADCipherDecryptor<...>`, which extend `SymmetricCipherEncryptor` / `SymmetricCipherDecryptor<...>`. The inherited methods are the AEAD + with no associated data and the tag inline (`ciphertext || tag`), so an AEAD can be held and + used as a plain symmetric cipher; `FINAL_LEN` is the tag plus anything the cipher holds back, + and every decryptor holds back the last `TAG_LEN` bytes it has seen, since it cannot know which + layout its final call will ask for. The AEAD traits add `do_update_aad`; the detached-tag + methods, each named for the base method it mirrors plus `_detached` (`do_final_detached` / + `do_final_out_detached`, `encrypt_out_detached`, `encrypt_out_rng_detached`, `encrypt_detached`, + `decrypt_out_detached`, `decrypt_detached` and the `*_len_detached` sizing helpers); and the + inline-tag one-shots with AAD, named for their base method plus `_with_aad` + (`encrypt_out_with_aad`, `encrypt_out_rng_with_aad`, `encrypt_with_aad`, `decrypt_out_with_aad`, + `decrypt_with_aad`). + `SymmetricCipherDecryptor::decrypt_out` now zeroizes what it wrote when `do_final` fails, as the + AEAD one-shots always have. The older single-type `core::traits::AEADCipher`, which this splits and which had no implementors, is removed, along with its `core-test-framework` suites (`TestFrameworkAEADCipher::test` / `::test_plain_one_shots`). Mutation testing of the pair's defaults (`traits.rs`, scoped to `AEADCipher{En,De}cryptor` and - tested through `bouncycastle-core` + `bouncycastle-ascon`) reports 116 mutants, 95 caught, 19 - unviable and 2 missed, the two being `written + final_len` -> `written - final_len` in - `encrypt_out_rng`, equivalent while every implementor has `FINAL_LEN = 0`. -* The same pair carries the inline `ciphertext || tag` layout that most wire formats and files - use, as four default methods rather than a separate adapter type: `tagged_encrypt` / - `tagged_do_aead_encrypt_final` append the tag to the ciphertext stream, and `tagged_decrypt` / - `tagged_do_aead_decrypt_final` take it back off the end of one. + `SymmetricCipherDecryptor::decrypt_out`, tested through `bouncycastle-core` + + `bouncycastle-ascon`) reports 134 mutants, 107 caught, 27 unviable and none missed; the + `AsconAead128Encryptor` / `AsconAead128Decryptor` adapters report 76 mutants, 49 caught, 27 + unviable and none missed. * ASCON testing covers the NIST LWC KAT sweeps from `bc-test-data` (1089 AEAD128, 1025 Hash256, 1025 XOF128 and 1089 CXOF128 cases when the data repository is present), plus embedded always-on vectors. Mutation testing for `bouncycastle-ascon` reports 661 mutants, 558 caught, 97 unviable diff --git a/cli/src/ascon_cmd.rs b/cli/src/ascon_cmd.rs index bd7fcdae..bdbd0470 100644 --- a/cli/src/ascon_cmd.rs +++ b/cli/src/ascon_cmd.rs @@ -11,7 +11,10 @@ use bouncycastle::core::errors::SymmetricCipherError; use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::traits::{AEADCipherDecryptor, AEADCipherEncryptor, SecurityStrength}; +use bouncycastle::core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, SecurityStrength, SymmetricCipherDecryptor, + SymmetricCipherEncryptor, +}; use bouncycastle::hex; use crate::helpers; @@ -142,7 +145,8 @@ pub(crate) fn aead128_cmd( } /// Generated-nonce encryption: drives [`AsconAead128Encryptor`] in the inline `ciphertext || tag` -/// layout (`tagged_do_aead_encrypt_final`), writing the nonce it generated ahead of the stream. +/// layout (the inherited [`SymmetricCipherEncryptor::do_final_out`]), writing the nonce it +/// generated ahead of the stream. /// With an explicit nonce there is no nonce to write, so that case goes to /// [`aead128_encrypt_stream_with_explicit_nonce`] instead. fn aead128_encrypt_stream( @@ -180,9 +184,9 @@ fn aead128_encrypt_stream( let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); helpers::write_bytes_or_hex(&out[..written], output_hex); } - // infallible: Ascon-AEAD128 has FINAL_LEN = 0, so `tail` only has to hold the 16-byte tag. + // infallible: Ascon-AEAD128 holds nothing back, so the inline final is only the 16-byte tag. let mut tail = [0u8; 16]; - let tail_len = cipher.tagged_do_aead_encrypt_final(&mut tail).unwrap(); + let tail_len = cipher.do_final_out(&mut tail).unwrap(); helpers::write_bytes_or_hex(&tail[..tail_len], output_hex); if output_hex { println!(); @@ -220,10 +224,9 @@ fn aead128_encrypt_stream_with_explicit_nonce( } /// Decrypts a stream whose final 16 bytes are the tag, which is only known once EOF is reached. -/// Everything but the last 16 bytes seen is released to [`AsconAead128Decryptor`] as soon as it is -/// known not to be part of the tag; what is left at EOF goes to -/// [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`], which decrypts any ciphertext still in it -/// and then checks the tag. +/// [`AsconAead128Decryptor`] holds the last 16 bytes it has seen back itself, releasing everything +/// before them as soon as it is known not to be part of the tag; at EOF +/// [`SymmetricCipherDecryptor::do_final`] checks what it held back as the tag. fn aead128_decrypt_stream( key: &KeyMaterial<16>, nonce: Option<&[u8; 16]>, @@ -231,7 +234,6 @@ fn aead128_decrypt_stream( output_hex: bool, ) { const CHUNK: usize = 1024; - const TAG_LEN: usize = 16; let nonce = match nonce { Some(nonce) => *nonce, None => { @@ -256,12 +258,6 @@ fn aead128_decrypt_stream( cipher.do_update_aad(ad).unwrap(); } - // The tag is the last TAG_LEN bytes of the stream, and nothing says where the stream ends - // until it does, so the last TAG_LEN bytes seen are always held back in `tail` and only - // released once something newer has arrived behind them. At EOF whatever is still in `tail` - // is the tag, which `tagged_do_aead_decrypt_final` checks. - let mut tail = [0u8; TAG_LEN]; - let mut tail_len = 0usize; let mut buf = [0u8; CHUNK]; let mut out = [0u8; CHUNK]; loop { @@ -269,44 +265,15 @@ fn aead128_decrypt_stream( if n == 0 { break; } - let total = tail_len + n; - if total <= TAG_LEN { - // Everything seen so far might still be the tag. - tail[tail_len..total].copy_from_slice(&buf[..n]); - tail_len = total; - continue; - } - - // Release the part of the old tail that is now known not to be the tag, then as much of - // the new input as is also known not to be; two calls over what is one contiguous run of - // ciphertext, which is the same to the cipher as one call over both. - let releasable = total - TAG_LEN; - let from_tail = tail_len.min(releasable); - let from_new = releasable - from_tail; - // infallible on both: `out` is CHUNK bytes and neither slice is longer than `buf`, and - // Ascon-AEAD128 writes exactly what it is given. - if from_tail > 0 { - let written = cipher.do_update_out(&tail[..from_tail], &mut out).unwrap(); - helpers::write_bytes_or_hex(&out[..written], output_hex); - } - if from_new > 0 { - let written = cipher.do_update_out(&buf[..from_new], &mut out).unwrap(); - helpers::write_bytes_or_hex(&out[..written], output_hex); - } - - // Whatever was not released is the new tail: the end of the old one, then the end of this - // read. Those are exactly TAG_LEN bytes, since `total - releasable == TAG_LEN`. - let mut new_tail = [0u8; TAG_LEN]; - let kept = tail_len - from_tail; - new_tail[..kept].copy_from_slice(&tail[from_tail..tail_len]); - new_tail[kept..].copy_from_slice(&buf[from_new..n]); - tail = new_tail; - tail_len = TAG_LEN; + // infallible: the decryptor releases at most what it has held back (16 bytes) plus what + // it is given, less the 16 it keeps, so never more than the `n <= CHUNK` bytes read. + let written = cipher.do_update_out(&buf[..n], &mut out).unwrap(); + helpers::write_bytes_or_hex(&out[..written], output_hex); } - match cipher.tagged_do_aead_decrypt_final(&tail[..tail_len], &mut out) { - Ok(last_len) => { - helpers::write_bytes_or_hex(&out[..last_len], output_hex); + match cipher.do_final() { + Ok((last, last_len)) => { + helpers::write_bytes_or_hex(&last[..last_len], output_hex); if output_hex { println!(); } diff --git a/crypto/ascon/src/ascon_aead128.rs b/crypto/ascon/src/ascon_aead128.rs index 582ebea6..a487227f 100644 --- a/crypto/ascon/src/ascon_aead128.rs +++ b/crypto/ascon/src/ascon_aead128.rs @@ -28,6 +28,7 @@ use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; use bouncycastle_core::suspendable_state::{add_lib_ver, check_lib_ver}; use bouncycastle_core::traits::{ AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, SuspendableKeyed, + SymmetricCipherDecryptor, SymmetricCipherEncryptor, }; use bouncycastle_rng::HashDRBG_SHA512; use bouncycastle_utils::ct::ct_eq_bytes; @@ -497,8 +498,13 @@ impl Algorithm for AsconAead128 { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; } -/// Adapts [`AsconAead128`]'s encrypting direction to [`AEADCipherEncryptor`]; see the module docs -/// for why this is a thin wrapper rather than a change to `AsconAead128` itself. +/// Adapts [`AsconAead128`]'s encrypting direction to [`AEADCipherEncryptor`] and, through it, +/// [`SymmetricCipherEncryptor`]; see the module docs for why this is a thin wrapper rather than a +/// change to `AsconAead128` itself. +/// +/// `FINAL_LEN` is `TAG_LEN`: Ascon-AEAD128 holds nothing back, so the inline +/// [`SymmetricCipherEncryptor::do_final`] writes only the tag, and the detached +/// [`AEADCipherEncryptor::do_final_out_detached`] writes nothing. pub struct AsconAead128Encryptor(AsconAead128); impl Algorithm for AsconAead128Encryptor { @@ -506,7 +512,7 @@ impl Algorithm for AsconAead128Encryptor { const MAX_SECURITY_STRENGTH: SecurityStrength = AsconAead128::MAX_SECURITY_STRENGTH; } -impl AEADCipherEncryptor for AsconAead128Encryptor { +impl SymmetricCipherEncryptor for AsconAead128Encryptor { fn do_encrypt_init( key: &KeyMaterial, ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { @@ -523,10 +529,6 @@ impl AEADCipherEncryptor for AsconAead128Encrypt Ok((Self(AsconAead128::new(key, &nonce, None, true)?), nonce)) } - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - self.0.do_update_aad(aad) - } - /// Ascon-AEAD128 never buffers: every byte given is a byte returned. fn update_out_len(&self, input_len: usize) -> usize { input_len @@ -546,39 +548,68 @@ impl AEADCipherEncryptor for AsconAead128Encrypt Ok(plaintext.len()) } - /// `output` is always `[u8; 0]`: nothing is ever held back to flush. - fn do_encrypt_final( + /// The inline layout: nothing is held back, so the final buffer is exactly the tag. + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + Ok((self.0.do_encrypt_final(), TAG_LEN)) + } + + /// The ciphertext, which is as long as the plaintext, followed by the tag. + fn encrypt_out_len(plaintext_len: usize) -> usize { + plaintext_len + TAG_LEN + } +} + +impl AEADCipherEncryptor for AsconAead128Encryptor { + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + self.0.do_update_aad(aad) + } + + /// Nothing is ever held back to flush, so `ciphertext` is left untouched. + fn do_final_out_detached( self, - _output: &mut [u8; 0], + _ciphertext: &mut [u8; TAG_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { Ok((0, self.0.do_encrypt_final())) } } -/// Adapts [`AsconAead128`]'s decrypting direction to [`AEADCipherDecryptor`]; see the module docs -/// for why this is a thin wrapper rather than a change to `AsconAead128` itself. -pub struct AsconAead128Decryptor(AsconAead128); +/// Adapts [`AsconAead128`]'s decrypting direction to [`AEADCipherDecryptor`] and, through it, +/// [`SymmetricCipherDecryptor`]; see the module docs for why this is a thin wrapper rather than a +/// change to `AsconAead128` itself. +/// +/// Unlike the inherent API this does hold data back: the last `TAG_LEN` bytes of ciphertext it has +/// seen, since until the stream ends it cannot know whether they are the inline tag +/// ([`SymmetricCipherDecryptor::do_final`]) or ciphertext with the tag carried separately +/// ([`AEADCipherDecryptor::do_final_out_detached`]). They are ciphertext, not plaintext, so they need +/// no [`Secret`] wrapper. +pub struct AsconAead128Decryptor { + cipher: AsconAead128, + // The most recent `held_len` bytes of ciphertext, not yet given to `cipher`. + held: [u8; TAG_LEN], + // Always `min(TAG_LEN, total ciphertext seen)`. + held_len: usize, +} impl Algorithm for AsconAead128Decryptor { const ALG_NAME: &'static str = AsconAead128::ALG_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = AsconAead128::MAX_SECURITY_STRENGTH; } -impl AEADCipherDecryptor for AsconAead128Decryptor { +impl SymmetricCipherDecryptor for AsconAead128Decryptor { fn do_decrypt_init( key: &KeyMaterial, nonce: &[u8; NONCE_LEN], ) -> Result { - Ok(Self(AsconAead128::new(key, nonce, None, false)?)) - } - - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - self.0.do_update_aad(aad) + Ok(Self { + cipher: AsconAead128::new(key, nonce, None, false)?, + held: [0u8; TAG_LEN], + held_len: 0, + }) } - /// Ascon-AEAD128 never buffers: every byte given is a byte returned. + /// Everything but the last `TAG_LEN` bytes seen so far is released. fn update_out_len(&self, input_len: usize) -> usize { - input_len + (self.held_len + input_len).saturating_sub(TAG_LEN) } fn do_update_out( @@ -586,23 +617,73 @@ impl AEADCipherDecryptor for AsconAead128Decrypt ciphertext: &[u8], plaintext: &mut [u8], ) -> Result { - if plaintext.len() < ciphertext.len() { - return Err(SymmetricCipherError::OutputBufferTooSmall(ciphertext.len())); + let release = self.update_out_len(ciphertext.len()); + if plaintext.len() < release { + return Err(SymmetricCipherError::OutputBufferTooSmall(release)); + } + // The oldest bytes go first: the held-back ones, then the front of `ciphertext`. + let from_held = release.min(self.held_len); + let from_input = release - from_held; + let out = &mut plaintext[..release]; + out[..from_held].copy_from_slice(&self.held[..from_held]); + out[from_held..].copy_from_slice(&ciphertext[..from_input]); + // Called even when `release` is 0: that is what ends the AAD phase in `cipher`, so a + // later non-empty `do_update_aad` is refused however little ciphertext has been seen. + self.cipher.do_decrypt_update(out); + // Keep the newest `TAG_LEN` (or fewer) bytes: what is left of `held`, then the tail of + // `ciphertext`. + let kept = self.held_len - from_held; + self.held.copy_within(from_held..self.held_len, 0); + let new_len = kept + ciphertext.len() - from_input; + self.held[kept..new_len].copy_from_slice(&ciphertext[from_input..]); + self.held_len = new_len; + Ok(release) + } + + /// The inline layout: the held-back bytes are the tag, so there is no plaintext left to + /// release. + /// + /// # Errors + /// [`SymmetricCipherError::DecryptionFailed`] if fewer than `TAG_LEN` bytes were seen in all; + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + if self.held_len < TAG_LEN { + return Err(SymmetricCipherError::DecryptionFailed); } - let out = &mut plaintext[..ciphertext.len()]; - out.copy_from_slice(ciphertext); - self.0.do_decrypt_update(out); - Ok(ciphertext.len()) + self.cipher.do_decrypt_final(&self.held)?; + Ok(([0u8; TAG_LEN], 0)) } - /// `output` is always `[u8; 0]`: nothing is ever held back to flush. - fn do_decrypt_final( - self, + /// Everything but the trailing tag. + fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + ciphertext_len.saturating_sub(TAG_LEN) + } +} + +impl AEADCipherDecryptor for AsconAead128Decryptor { + /// # Errors + /// [`SymmetricCipherError::StateError`] if `aad` is non-empty and + /// [`SymmetricCipherDecryptor::do_update_out`] has already been called. + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + self.cipher.do_update_aad(aad) + } + + /// The held-back bytes are ciphertext: decrypts them into `plaintext`, then checks `tag`. On a + /// failed check `plaintext` is zeroized, so the error leaves nothing unauthenticated behind in + /// it (what earlier `do_update_out` calls released is the caller's to scrub). + fn do_final_out_detached( + mut self, tag: &[u8; TAG_LEN], - _output: &mut [u8; 0], + plaintext: &mut [u8; TAG_LEN], ) -> Result { - self.0.do_decrypt_final(tag)?; - Ok(0) + let n = self.held_len; + plaintext[..n].copy_from_slice(&self.held[..n]); + self.cipher.do_decrypt_update(&mut plaintext[..n]); + if let Err(e) = self.cipher.do_decrypt_final(tag) { + plaintext.fill(0); + return Err(e); + } + Ok(n) } } diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index 017aad46..9f428857 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -50,11 +50,14 @@ //! assert_eq!(&pt, plaintext); //! ``` //! -//! Authenticated encryption (streaming, detached tag): +//! Authenticated encryption (streaming, detached tag). The decryptor holds back the last 16 +//! bytes it has seen, in case they are an inline tag, so `do_final_out_detached` is where they come out: //! ``` //! use bouncycastle_ascon::ascon_aead128::{AsconAead128Decryptor, AsconAead128Encryptor}; //! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; -//! use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +//! use bouncycastle_core::traits::{ +//! AEADCipherDecryptor, AEADCipherEncryptor, SymmetricCipherDecryptor, SymmetricCipherEncryptor, +//! }; //! //! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); //! @@ -63,35 +66,45 @@ //! enc.do_update_aad(b"associated data").unwrap(); //! let mut ciphertext = [0u8; 16]; //! enc.do_update_out(plaintext, &mut ciphertext).unwrap(); -//! let mut final_buf = [0u8; 0]; -//! let (_, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); +//! let mut final_buf = [0u8; 16]; +//! let (_, tag) = enc.do_final_out_detached(&mut final_buf).unwrap(); //! //! let mut dec = AsconAead128Decryptor::do_decrypt_init(&key, &nonce).unwrap(); //! dec.do_update_aad(b"associated data").unwrap(); //! let mut recovered = [0u8; 16]; -//! dec.do_update_out(&ciphertext, &mut recovered).unwrap(); -//! dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); // now authenticated +//! let n = dec.do_update_out(&ciphertext, &mut recovered).unwrap(); // 0: all 16 held back +//! let m = dec.do_final_out_detached(&tag, &mut final_buf).unwrap(); // now authenticated +//! recovered[n..n + m].copy_from_slice(&final_buf[..m]); //! assert_eq!(&recovered, plaintext); //! ``` //! -//! For the inline `ciphertext || tag` layout that most wire formats and files use, the same pair -//! has [`bouncycastle_core::traits::AEADCipherEncryptor::tagged_encrypt`] / -//! [`bouncycastle_core::traits::AEADCipherDecryptor::tagged_decrypt`] as one-shots, and -//! `tagged_do_aead_encrypt_final` / `tagged_do_aead_decrypt_final` for streaming: +//! For the inline `ciphertext || tag` layout that most wire formats and files use, the pair is +//! also a [`bouncycastle_core::traits::SymmetricCipherEncryptor`] / +//! [`bouncycastle_core::traits::SymmetricCipherDecryptor`], which covers the no-AAD case -- +//! streaming, or through its `encrypt_out` / `decrypt_out` one-shots -- and +//! [`bouncycastle_core::traits::AEADCipherEncryptor::encrypt_out_with_aad`] / +//! [`bouncycastle_core::traits::AEADCipherDecryptor::decrypt_out_with_aad`] are the one-shots with AAD: //! ``` //! use bouncycastle_ascon::ascon_aead128::{AsconAead128Decryptor, AsconAead128Encryptor}; //! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; -//! use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +//! use bouncycastle_core::traits::{ +//! AEADCipherDecryptor, AEADCipherEncryptor, SymmetricCipherDecryptor, SymmetricCipherEncryptor, +//! }; //! //! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42u8; 16], KeyType::SymmetricCipherKey).unwrap(); //! let plaintext = b"secret message!!"; //! -//! let mut inline = [0u8; 32]; // AsconAead128Encryptor::tagged_encrypt_out_len(16) -//! let (nonce, len) = AsconAead128Encryptor::tagged_encrypt(&key, b"", plaintext, &mut inline).unwrap(); +//! // No AAD: just a symmetric cipher. +//! let mut inline = [0u8; 32]; // AsconAead128Encryptor::encrypt_out_len(16) +//! let (nonce, len) = AsconAead128Encryptor::encrypt_out(&key, plaintext, &mut inline).unwrap(); //! assert_eq!(len, plaintext.len() + 16); // ciphertext || tag -//! //! let mut recovered = [0u8; 16]; -//! let n = AsconAead128Decryptor::tagged_decrypt(&key, &nonce, b"", &inline[..len], &mut recovered).unwrap(); +//! let n = AsconAead128Decryptor::decrypt_out(&key, &nonce, &inline[..len], &mut recovered).unwrap(); +//! assert_eq!(&recovered[..n], plaintext); +//! +//! // With AAD. +//! let (nonce, len) = AsconAead128Encryptor::encrypt_out_with_aad(&key, b"aad", plaintext, &mut inline).unwrap(); +//! let n = AsconAead128Decryptor::decrypt_out_with_aad(&key, &nonce, b"aad", &inline[..len], &mut recovered).unwrap(); //! assert_eq!(&recovered[..n], plaintext); //! ``` //! @@ -132,11 +145,15 @@ //! caller that needs a partial-byte final block should reach for SHA-3, which supports one. //! - **Decryption tag check failure:** a ciphertext decryption whose finalization returns //! `Err(SymmetricCipherError::AEADTagCheckFailed)` must be treated as tampered, and the entire -//! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`] and -//! [`bouncycastle_core::traits::AEADCipherDecryptor::decrypt_out`]) zeroize their output buffer -//! before returning that error. The streaming API ([`ascon_aead128::AsconAead128::do_decrypt_update`] / -//! [`ascon_aead128::AsconAead128::do_decrypt_final`] or -//! [`bouncycastle_core::traits::AEADCipherDecryptor::do_decrypt_final`]) does not: plaintext +//! plaintext rejected. The one-shot APIs ([`ascon_aead128::AsconAead128::decrypt`], +//! [`bouncycastle_core::traits::AEADCipherDecryptor::decrypt_out_detached`], +//! [`bouncycastle_core::traits::AEADCipherDecryptor::decrypt_out_with_aad`] and +//! [`bouncycastle_core::traits::SymmetricCipherDecryptor::decrypt_out`]) zeroize their output +//! buffer before returning that error. The streaming API +//! ([`ascon_aead128::AsconAead128::do_decrypt_update`] / +//! [`ascon_aead128::AsconAead128::do_decrypt_final`], or `do_update_out` followed by +//! [`bouncycastle_core::traits::AEADCipherDecryptor::do_final_out_detached`] or +//! [`bouncycastle_core::traits::SymmetricCipherDecryptor::do_final`]) does not: plaintext //! bytes are necessarily written to the caller's buffer *before* the tag can be checked, so an //! application streaming a large plaintext must have a way to cancel the operation or //! transaction if finalization returns an error. diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs index 29b5795a..e055f55b 100644 --- a/crypto/ascon/tests/aead128_tests.rs +++ b/crypto/ascon/tests/aead128_tests.rs @@ -6,7 +6,8 @@ //! failures, determinism), driven through the inherent explicit-nonce API. //! - The shared conformance framework (`core-test-framework`), which exercises the //! `AEADCipherEncryptor`/`AEADCipherDecryptor` pair, with internally-generated nonces, in both -//! the detached-tag and the inline `ciphertext || tag` (`tagged_*`) layouts. +//! the detached-tag (`*_detached`) and the inline `ciphertext || tag` layouts -- the latter also +//! through the `SymmetricCipherEncryptor`/`SymmetricCipherDecryptor` traits they extend. use bouncycastle_ascon::ascon_aead128::{ AsconAead128, AsconAead128Decryptor, AsconAead128Encryptor, @@ -484,7 +485,7 @@ fn do_decrypt_update_on_encryptor_panics() { #[test] fn aead128_encryptor_decryptor_trait_framework() { TestFrameworkAEADCipher::new() - .test_encryptor_decryptor::<16, 16, 16, 0, AsconAead128Encryptor, AsconAead128Decryptor>(); + .test_encryptor_decryptor::<16, 16, 16, 16, AsconAead128Encryptor, AsconAead128Decryptor>(); } #[test] @@ -493,13 +494,16 @@ fn aead_framework_buffering_toy() { } /// The two tag layouts must agree byte for byte: `direct_ciphertext || direct_tag`, produced by -/// streaming [`AsconAead128Encryptor`] and taking the tag from `do_encrypt_final`, must equal what +/// streaming [`AsconAead128Encryptor`] and taking the tag from `do_final_out_detached`, must equal what /// the inline layout produces for the same key, nonce (driven by the same RNG stream), AAD and -/// message -- through both `tagged_encrypt` and `tagged_do_aead_encrypt_final` -- and either must +/// message -- through both `encrypt_out_with_aad` and the inherited `do_final` -- and either must /// decrypt back to the original plaintext. #[test] fn aead128_tagged_and_direct_layouts_agree() { - use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; + use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, SymmetricCipherDecryptor, + SymmetricCipherEncryptor, + }; use bouncycastle_core_test_framework::FixedSeedRNG; let km = key_material(&KEY); @@ -515,8 +519,9 @@ fn aead128_tagged_and_direct_layouts_agree() { direct_enc.do_update_aad(aad).unwrap(); let mut direct_ct = vec![0u8; pt.len()]; direct_enc.do_update_out(&pt, &mut direct_ct).unwrap(); - let mut nothing = [0u8; 0]; - let (_, direct_tag) = direct_enc.do_encrypt_final(&mut nothing).unwrap(); + let mut unused = [0u8; 16]; + let (flushed, direct_tag) = direct_enc.do_final_out_detached(&mut unused).unwrap(); + assert_eq!(flushed, 0, "Ascon-AEAD128 holds nothing back to flush"); let mut direct_inline = direct_ct.clone(); direct_inline.extend_from_slice(&direct_tag); @@ -525,22 +530,24 @@ fn aead128_tagged_and_direct_layouts_agree() { AsconAead128Encryptor::do_encrypt_init_rng(&km, &mut FixedSeedRNG::<16>::new(pinned)) .unwrap(); tagged_enc.do_update_aad(aad).unwrap(); - let mut tagged_out = vec![0u8; AsconAead128Encryptor::tagged_encrypt_out_len(pt.len())]; - let mut written = tagged_enc.do_update_out(&pt, &mut tagged_out).unwrap(); - written += tagged_enc.tagged_do_aead_encrypt_final(&mut tagged_out[written..]).unwrap(); - tagged_out.truncate(written); + let mut tagged_out = vec![0u8; AsconAead128Encryptor::encrypt_out_len(pt.len())]; + let written = tagged_enc.do_update_out(&pt, &mut tagged_out).unwrap(); + let mut last = [0u8; 16]; + let last_len = tagged_enc.do_final_out(&mut last).unwrap(); + tagged_out[written..written + last_len].copy_from_slice(&last[..last_len]); + tagged_out.truncate(written + last_len); assert_eq!(direct_nonce, tagged_nonce, "pt_len {pt_len}: same RNG stream, same nonce"); assert_eq!(direct_inline, tagged_out, "pt_len {pt_len}: inline layout must agree"); // inline tag, one-shot: its own generated nonce, so what must match is the round trip // and the length, not the bytes. - let mut one_shot = vec![0u8; AsconAead128Encryptor::tagged_encrypt_out_len(pt.len())]; + let mut one_shot = vec![0u8; AsconAead128Encryptor::encrypt_out_len(pt.len())]; let (one_nonce, one_len) = - AsconAead128Encryptor::tagged_encrypt(&km, aad, &pt, &mut one_shot).unwrap(); + AsconAead128Encryptor::encrypt_out_with_aad(&km, aad, &pt, &mut one_shot).unwrap(); assert_eq!(one_len, tagged_out.len(), "pt_len {pt_len}: one-shot writes the same length"); - let mut one_back = vec![0u8; AsconAead128Decryptor::tagged_decrypt_out_max_len(one_len)]; - let one_n = AsconAead128Decryptor::tagged_decrypt( + let mut one_back = vec![0u8; AsconAead128Decryptor::decrypt_out_max_len(one_len)]; + let one_n = AsconAead128Decryptor::decrypt_out_with_aad( &km, &one_nonce, aad, @@ -550,27 +557,30 @@ fn aead128_tagged_and_direct_layouts_agree() { .unwrap(); assert_eq!(&one_back[..one_n], &pt[..], "pt_len {pt_len}: one-shot round trip"); - // ...and all of it decrypts back, each through its own view. + // ...and all of it decrypts back, each through its own view. The decryptor holds the + // last 16 bytes back either way; detached, `do_final_out_detached` releases them. let mut direct_dec = AsconAead128Decryptor::do_decrypt_init(&km, &direct_nonce).unwrap(); direct_dec.do_update_aad(aad).unwrap(); let mut direct_pt = vec![0u8; direct_ct.len()]; - direct_dec.do_update_out(&direct_ct, &mut direct_pt).unwrap(); - direct_dec.do_decrypt_final(&direct_tag, &mut nothing).unwrap(); + let got = direct_dec.do_update_out(&direct_ct, &mut direct_pt).unwrap(); + assert_eq!(got, pt_len.saturating_sub(16), "pt_len {pt_len}: the last 16 bytes are held"); + let mut last = [0u8; 16]; + let last_len = direct_dec.do_final_out_detached(&direct_tag, &mut last).unwrap(); + assert_eq!(got + last_len, pt_len, "pt_len {pt_len}: detached final releases the rest"); + direct_pt[got..].copy_from_slice(&last[..last_len]); assert_eq!(direct_pt, pt, "pt_len {pt_len}: direct decrypt round trip"); let mut tagged_dec = AsconAead128Decryptor::do_decrypt_init(&km, &tagged_nonce).unwrap(); tagged_dec.do_update_aad(aad).unwrap(); - let body = tagged_out.len() - 16; let mut tagged_pt = vec![0u8; tagged_out.len()]; - let mut got = tagged_dec.do_update_out(&tagged_out[..body], &mut tagged_pt).unwrap(); - got += tagged_dec - .tagged_do_aead_decrypt_final(&tagged_out[body..], &mut tagged_pt[got..]) - .unwrap(); + let got = tagged_dec.do_update_out(&tagged_out, &mut tagged_pt).unwrap(); + assert_eq!(got, pt_len, "pt_len {pt_len}: all but the tag is released"); + let (_, data_len) = tagged_dec.do_final().unwrap(); + assert_eq!(data_len, 0, "pt_len {pt_len}: nothing but the tag was held back"); assert_eq!(&tagged_pt[..got], &pt[..], "pt_len {pt_len}: tagged decrypt round trip"); - let mut one_pt = - vec![0u8; AsconAead128Decryptor::tagged_decrypt_out_max_len(tagged_out.len())]; - let n = AsconAead128Decryptor::tagged_decrypt( + let mut one_pt = vec![0u8; AsconAead128Decryptor::decrypt_out_max_len(tagged_out.len())]; + let n = AsconAead128Decryptor::decrypt_out_with_aad( &km, &tagged_nonce, aad, &tagged_out, &mut one_pt, ) .unwrap(); @@ -578,6 +588,44 @@ fn aead128_tagged_and_direct_layouts_agree() { } } +/// With no associated data, the pair used purely as a [`SymmetricCipherEncryptor`] / +/// [`SymmetricCipherDecryptor`] -- nonce driven to the KAT's by a fixed RNG -- reproduces the +/// embedded NIST LWC vectors' `ciphertext || tag`, and decrypts them back. +/// +/// [`SymmetricCipherEncryptor`]: bouncycastle_core::traits::SymmetricCipherEncryptor +/// [`SymmetricCipherDecryptor`]: bouncycastle_core::traits::SymmetricCipherDecryptor +#[test] +fn aead128_symmetric_cipher_view_matches_kat() { + use bouncycastle_core::traits::{SymmetricCipherDecryptor, SymmetricCipherEncryptor}; + use bouncycastle_core_test_framework::FixedSeedRNG; + + let km = key_material(&KEY); + // The NIST LWC AEAD KAT convention uses Key == Nonce == 000102…0F (i.e. KEY for both). + let kat_nonce = KEY; + let mut tested = 0; + for (pt_hex, ad_hex, ct_hex) in AEAD_KAT.iter().filter(|(_, ad, _)| ad.is_empty()) { + let pt = dh(pt_hex); + let expected = dh(ct_hex); + assert!(dh(ad_hex).is_empty()); + + let mut ct = vec![0u8; AsconAead128Encryptor::encrypt_out_len(pt.len())]; + let (nonce, n) = AsconAead128Encryptor::encrypt_out_rng( + &km, + &mut FixedSeedRNG::<16>::new(kat_nonce), + &pt, + &mut ct, + ) + .unwrap(); + assert_eq!(nonce, kat_nonce); + assert_eq!(&ct[..n], &expected[..], "pt {pt_hex}: SymmetricCipherEncryptor view vs KAT"); + + let recovered = AsconAead128Decryptor::decrypt(&km, &kat_nonce, &expected).unwrap(); + assert_eq!(recovered, pt, "pt {pt_hex}: SymmetricCipherDecryptor view vs KAT"); + tested += 1; + } + assert!(tested >= 2, "the embedded KATs must include no-AD vectors"); +} + #[test] fn aead128_suspendable_keyed_state() { use bouncycastle_core::errors::SuspendableError; diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 78663fcb..cbdc9148 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -217,6 +217,14 @@ impl TestFrameworkSymmetricCipher { other => panic!("decrypt_out into a short buffer: {other:?}"), } } + // ...and ones with room to spare are accepted: without this each `<` guard can be flipped + // to `>` and the short-buffer probes above still "pass". + let mut roomy = vec![0u8; E::encrypt_out_len(len) + 3]; + let (_, n) = E::encrypt_out(&key, msg, &mut roomy).unwrap(); + assert_eq!(n, E::encrypt_out_len(len), "encrypt_out into a roomy buffer"); + let mut roomy = vec![0u8; need + 3]; + let n = D::decrypt_out(&key, &init_data, &ct[..ct_len], &mut roomy).unwrap(); + assert_eq!(&roomy[..n], msg, "decrypt_out into a roomy buffer"); let (mut enc, _) = E::do_encrypt_init(&key).unwrap(); let need = enc.update_out_len(len); if need > 0 { @@ -465,8 +473,14 @@ impl TestFrameworkAEADCipher { /// authenticated cipher. /// /// Checks, in order: - /// * the one-shot round trip for every message length from 0 to a few times `TAG_LEN`, and - /// that the tag is not the all-zero array; + /// * the whole [`TestFrameworkSymmetricCipher::test_encryptor_decryptor`] suite, since an AEAD + /// with no associated data and the tag inline *is* a [`SymmetricCipherEncryptor`] / + /// [`SymmetricCipherDecryptor`] pair, and that `FINAL_LEN` has room for the tag; + /// * the detached one-shot round trip for every message length from 0 to a few times + /// `TAG_LEN`, and that the tag is not the all-zero array; + /// * the inline layout with associated data, one-shot and streaming, is exactly the detached + /// ciphertext with the tag appended, and a stream shorter than the tag is a failed + /// decryption; /// * streaming in every chunking, of both the AAD and the data, agrees with `update_out_len` /// on every call and gives the one-shot's ciphertext and tag byte for byte, and decrypts in /// every chunking; @@ -474,18 +488,14 @@ impl TestFrameworkAEADCipher { /// with no data still authenticates its AAD; /// * `do_update_aad` with non-empty AAD after the first `do_update_out` is refused with a /// [`SymmetricCipherError::StateError`], and the refusal leaves the value usable; - /// * a tampered ciphertext, tag, AAD or nonce all fail the tag check, and the one-shot - /// `decrypt` leaves no plaintext behind when they do; + /// * a tampered ciphertext, tag, AAD or nonce all fail the tag check, and the one-shots leave + /// no plaintext behind when they do; /// * two encryptions under the same key draw different nonces; /// * a key of the wrong [`KeyType`] is rejected, and the security-strength policy matches /// [`Algorithm::MAX_SECURITY_STRENGTH`]. /// - /// This only ever drives `E`/`D` with `FINAL_LEN` bytes-or-fewer actually flushed at - /// finalization; it does not by itself prove that a *genuinely buffering* implementor's - /// `update_out_len` is honoured mid-stream (nothing here ever expects `do_update_out` to - /// return less than it was given). [`Self::test_buffering_toy`] pins that separately, against - /// a toy built to hold data back, since `E`/`D` here are supplied by the caller and might not - /// exercise it. + /// [`Self::test_buffering_toy`] separately pins that a cipher which holds back more than the + /// tag is handled correctly, since `E`/`D` here are supplied by the caller and might not. /// /// [`Algorithm::MAX_SECURITY_STRENGTH`]: bouncycastle_core::traits::Algorithm::MAX_SECURITY_STRENGTH pub fn test_encryptor_decryptor< @@ -498,19 +508,28 @@ impl TestFrameworkAEADCipher { >( &self, ) { + assert!( + FINAL_LEN >= TAG_LEN, + "FINAL_LEN must have room for the inline tag the decryptor holds back" + ); + // No AAD and the tag inline is the plain symmetric-cipher contract. + TestFrameworkSymmetricCipher::new() + .test_encryptor_decryptor::(); + let key = KeyMaterial::::from_bytes_as_type( &DUMMY_SEED[..KEY_LEN], KeyType::SymmetricCipherKey, ) .unwrap(); let aad: &[u8] = b"some associated data"; + let pinned = [0xA5u8; NONCE_LEN]; // one-shot round trip, every length up to a few times the tag length let max_len = 3 * TAG_LEN.max(1) + 5; for len in 0..=max_len { let msg = &DUMMY_SEED[..len]; - let mut ct = vec![0u8; E::encrypt_out_len(len)]; - let (nonce, ct_len, tag) = E::encrypt_out(&key, aad, msg, &mut ct).unwrap(); + let mut ct = vec![0u8; E::encrypt_out_len_detached(len)]; + let (nonce, ct_len, tag) = E::encrypt_out_detached(&key, aad, msg, &mut ct).unwrap(); ct.truncate(ct_len); assert_ne!(tag, [0u8; TAG_LEN], "len {len}: the tag must not be all zeros"); // Only assert the ciphertext differs from the plaintext once there is enough of it for @@ -518,84 +537,152 @@ impl TestFrameworkAEADCipher { if len >= 8 { assert_ne!(&ct[..], msg, "len {len}: the ciphertext must not be the plaintext"); } - let mut pt = vec![0u8; D::decrypt_out_max_len(ct.len())]; - let pt_len = D::decrypt_out(&key, &nonce, aad, &ct, &tag, &mut pt).unwrap(); + let mut pt = vec![0u8; D::decrypt_out_max_len_detached(ct.len())]; + let pt_len = D::decrypt_out_detached(&key, &nonce, aad, &ct, &tag, &mut pt).unwrap(); pt.truncate(pt_len); assert_eq!(&pt[..], msg, "one-shot round trip, len {len}"); // the std one-shots agree with the _out ones for the same nonce - let (nonce2, ct2, tag2) = E::encrypt(&key, aad, msg).unwrap(); - assert_eq!(ct2.len(), ct_len, "encrypt must return exactly the bytes written"); - let pt2 = D::decrypt(&key, &nonce2, aad, &ct2, &tag2).unwrap(); + let (nonce2, ct2, tag2) = E::encrypt_detached(&key, aad, msg).unwrap(); + assert_eq!(ct2.len(), ct_len, "encrypt_detached must return exactly the bytes written"); + let pt2 = D::decrypt_detached(&key, &nonce2, aad, &ct2, &tag2).unwrap(); assert_eq!(pt2, msg, "std round trip, len {len}"); - let pt3 = D::decrypt(&key, &nonce, aad, &ct, &tag).unwrap(); - assert_eq!(pt3, msg, "decrypt must agree with decrypt_out"); + let pt3 = D::decrypt_detached(&key, &nonce, aad, &ct, &tag).unwrap(); + assert_eq!(pt3, msg, "decrypt_detached must agree with decrypt_out_detached"); + + // the inline `ciphertext || tag` layout with AAD: `encrypt_out_with_aad` must write exactly + // the detached ciphertext with the tag appended -- the same bytes under the same + // nonce -- and both the one-shot and the streaming finalizer must round trip it. + let mut detached = vec![0u8; E::encrypt_out_len_detached(len)]; + let (pinned_nonce, detached_len, detached_tag) = E::encrypt_out_rng_detached( + &key, + &mut FixedSeedRNG::::new(pinned), + aad, + msg, + &mut detached, + ) + .unwrap(); + detached.truncate(detached_len); + detached.extend_from_slice(&detached_tag); - // the inline `ciphertext || tag` layout: `tagged_encrypt` must write exactly the - // separate-tag ciphertext with the tag appended, and both the one-shot and the - // streaming finalizer must round trip it. - let mut inline = vec![0u8; E::tagged_encrypt_out_len(len)]; + let mut inline = vec![0u8; E::encrypt_out_len(len)]; let (inline_nonce, inline_len) = - E::tagged_encrypt(&key, aad, msg, &mut inline).unwrap(); + E::encrypt_out_with_aad(&key, aad, msg, &mut inline).unwrap(); assert_eq!( inline_len, - E::encrypt_out_len(len) + TAG_LEN, - "tagged_encrypt must write the ciphertext plus the tag, len {len}" + E::encrypt_out_len_detached(len) + TAG_LEN, + "encrypt_out_with_aad must write the ciphertext plus the tag, len {len}" ); - let mut pt4 = vec![0u8; D::tagged_decrypt_out_max_len(inline_len)]; + let mut pt4 = vec![0u8; D::decrypt_out_max_len(inline_len)]; let pt4_len = - D::tagged_decrypt(&key, &inline_nonce, aad, &inline[..inline_len], &mut pt4) + D::decrypt_out_with_aad(&key, &inline_nonce, aad, &inline[..inline_len], &mut pt4) .unwrap(); assert_eq!(&pt4[..pt4_len], msg, "tagged one-shot round trip, len {len}"); - let (mut enc5, nonce5) = E::do_encrypt_init(&key).unwrap(); + // ...and so must the RNG-driven and allocating inline-with-AAD one-shots. The roomy + // buffer is deliberate: see the `encrypt_out_rng_detached` probe below. + let mut inline_rng = vec![0u8; E::encrypt_out_len(len) + 3]; + let (rng_nonce, rng_len) = E::encrypt_out_rng_with_aad( + &key, + &mut FixedSeedRNG::::new(pinned), + aad, + msg, + &mut inline_rng, + ) + .unwrap(); + assert_eq!(rng_nonce, pinned_nonce, "the same RNG stream must give the same nonce"); + assert_eq!( + &inline_rng[..rng_len], + &detached[..], + "len {len}: encrypt_out_rng_with_aad must be the detached ciphertext and its tag" + ); + // exactly the length it asks for must be enough too + let mut exact = vec![0u8; E::encrypt_out_len(len)]; + let (_, exact_len) = E::encrypt_out_rng_with_aad( + &key, + &mut FixedSeedRNG::::new(pinned), + aad, + msg, + &mut exact, + ) + .unwrap(); + assert_eq!(&exact[..exact_len], &detached[..], "len {len}: exact-size buffer"); + let mut short = vec![0u8; E::encrypt_out_len(len) - 1]; + match E::encrypt_out_rng_with_aad( + &key, + &mut FixedSeedRNG::::new(pinned), + aad, + msg, + &mut short, + ) { + Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { + assert_eq!(n, E::encrypt_out_len(len)) + } + other => panic!("encrypt_out_rng_with_aad into a short buffer: {other:?}"), + } + let (alloc_nonce, alloc_ct) = E::encrypt_with_aad(&key, aad, msg).unwrap(); + assert_eq!( + alloc_ct.len(), + inline_len, + "encrypt_with_aad must return the bytes written" + ); + let alloc_pt = D::decrypt_with_aad(&key, &alloc_nonce, aad, &alloc_ct).unwrap(); + assert_eq!(alloc_pt, msg, "allocating inline-with-AAD round trip, len {len}"); + + let (mut enc5, nonce5) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(pinned)).unwrap(); + assert_eq!(nonce5, pinned_nonce, "the same RNG stream must give the same nonce"); enc5.do_update_aad(aad).unwrap(); - // `+ FINAL_LEN`: the finalizer wants room for a full flush plus the tag at the tail, - // which it cannot know the size of before it runs. - let mut inline5 = vec![0u8; E::tagged_encrypt_out_len(len) + FINAL_LEN]; - let mut written5 = enc5.do_update_out(msg, &mut inline5).unwrap(); - written5 += enc5.tagged_do_aead_encrypt_final(&mut inline5[written5..]).unwrap(); + let mut inline5 = vec![0u8; enc5.update_out_len(len)]; + let written5 = enc5.do_update_out(msg, &mut inline5).unwrap(); + inline5.truncate(written5); + let (last5, last5_len) = enc5.do_final().unwrap(); + inline5.extend_from_slice(&last5[..last5_len]); + assert_eq!( + inline5.len(), + inline_len, + "tagged streaming must write as much as the one-shot" + ); assert_eq!( - written5, inline_len, - "tagged streaming must write as much as the one-shot, len {len}" + inline5, detached, + "len {len}: the inline layout must be the detached ciphertext followed by its tag" ); - let body5 = written5 - TAG_LEN; let mut dec5 = D::do_decrypt_init(&key, &nonce5).unwrap(); dec5.do_update_aad(aad).unwrap(); - let mut pt5 = vec![0u8; written5 + FINAL_LEN]; - let mut got5 = dec5.do_update_out(&inline5[..body5], &mut pt5).unwrap(); - got5 += dec5 - .tagged_do_aead_decrypt_final(&inline5[body5..written5], &mut pt5[got5..]) - .unwrap(); - assert_eq!(&pt5[..got5], msg, "tagged streaming round trip, len {len}"); + let mut pt5 = vec![0u8; dec5.update_out_len(inline5.len())]; + let got5 = dec5.do_update_out(&inline5, &mut pt5).unwrap(); + pt5.truncate(got5); + let (last, data_len) = dec5.do_final().unwrap(); + pt5.extend_from_slice(&last[..data_len]); + assert_eq!(pt5, msg, "tagged streaming round trip, len {len}"); // a stream that ends before a whole tag has been seen is not a short buffer, it is a // failed decryption if TAG_LEN > 0 { - let dec6 = D::do_decrypt_init(&key, &nonce5).unwrap(); - let mut scratch = vec![0u8; written5 + FINAL_LEN]; + let mut dec6 = D::do_decrypt_init(&key, &nonce5).unwrap(); + dec6.do_update_aad(aad).unwrap(); + let short = &inline5[..TAG_LEN - 1]; + let mut scratch = vec![0u8; dec6.update_out_len(short.len())]; + dec6.do_update_out(short, &mut scratch).unwrap(); assert!( - matches!( - dec6.tagged_do_aead_decrypt_final(&inline5[..TAG_LEN - 1], &mut scratch), - Err(SymmetricCipherError::DecryptionFailed) - ), - "a tail shorter than the tag must be DecryptionFailed, len {len}" + matches!(dec6.do_final(), Err(SymmetricCipherError::DecryptionFailed)), + "a stream shorter than the tag must be DecryptionFailed, len {len}" ); } // too-short output buffers on the one-shots are refused with the required length, // before any work is done - let need = E::encrypt_out_len(len); + let need = E::encrypt_out_len_detached(len); if need > 0 { let mut short = vec![0u8; need - 1]; - match E::encrypt_out(&key, aad, msg, &mut short) { + match E::encrypt_out_detached(&key, aad, msg, &mut short) { Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { assert_eq!(n, need) } - other => panic!("encrypt_out into a short buffer: {other:?}"), + other => panic!("encrypt_out_detached into a short buffer: {other:?}"), } let mut short = vec![0u8; need - 1]; - match E::encrypt_out_rng( + match E::encrypt_out_rng_detached( &key, &mut FixedSeedRNG::::new([0xA5u8; NONCE_LEN]), aad, @@ -605,13 +692,16 @@ impl TestFrameworkAEADCipher { Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { assert_eq!(n, need) } - other => panic!("encrypt_out_rng into a short buffer: {other:?}"), + other => panic!("encrypt_out_rng_detached into a short buffer: {other:?}"), } // ...and one with room to spare must be accepted: without this the guard can be // flipped to `>` and every short-buffer probe still "passes", because the error // then comes from `do_update_out` behind it with the same variant and length. let mut roomy = vec![0u8; need + 3]; - let (_, n, _) = E::encrypt_out_rng( + let (_, n, _) = E::encrypt_out_detached(&key, aad, msg, &mut roomy).unwrap(); + assert_eq!(n, need, "encrypt_out_detached into a roomy buffer"); + let mut roomy = vec![0u8; need + 3]; + let (_, n, _) = E::encrypt_out_rng_detached( &key, &mut FixedSeedRNG::::new([0xA5u8; NONCE_LEN]), aad, @@ -619,16 +709,35 @@ impl TestFrameworkAEADCipher { &mut roomy, ) .unwrap(); - assert_eq!(n, need, "encrypt_out_rng must write exactly encrypt_out_len bytes"); + assert_eq!( + n, need, + "encrypt_out_rng_detached must write exactly encrypt_out_len_detached bytes" + ); } - let need = D::decrypt_out_max_len(ct.len()); + let need = E::encrypt_out_len(len); + let mut short = vec![0u8; need - 1]; + match E::encrypt_out_with_aad(&key, aad, msg, &mut short) { + Err(SymmetricCipherError::OutputBufferTooSmall(n)) => assert_eq!(n, need), + other => panic!("encrypt_out_with_aad into a short buffer: {other:?}"), + } + let need = D::decrypt_out_max_len_detached(ct.len()); if need > 0 { let mut short = vec![0u8; need - 1]; - match D::decrypt_out(&key, &nonce, aad, &ct, &tag, &mut short) { + match D::decrypt_out_detached(&key, &nonce, aad, &ct, &tag, &mut short) { Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { assert_eq!(n, need) } - other => panic!("decrypt_out into a short buffer: {other:?}"), + other => panic!("decrypt_out_detached into a short buffer: {other:?}"), + } + } + let need = D::decrypt_out_max_len(inline_len); + if need > 0 { + let mut short = vec![0u8; need - 1]; + match D::decrypt_out_with_aad(&key, &inline_nonce, aad, &inline, &mut short) { + Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { + assert_eq!(n, need) + } + other => panic!("decrypt_out_with_aad into a short buffer: {other:?}"), } } } @@ -636,9 +745,8 @@ impl TestFrameworkAEADCipher { // streaming in every chunking agrees with the one-shot, for both the AAD and the data. // The pinned RNG is what makes the nonce -- and so the ciphertext -- comparable. let msg = &DUMMY_SEED[..max_len.max(17)]; - let pinned = [0xA5u8; NONCE_LEN]; - let mut ct_ref = vec![0u8; E::encrypt_out_len(msg.len())]; - let (nonce_ref, ct_ref_len, tag_ref) = E::encrypt_out_rng( + let mut ct_ref = vec![0u8; E::encrypt_out_len_detached(msg.len())]; + let (nonce_ref, ct_ref_len, tag_ref) = E::encrypt_out_rng_detached( &key, &mut FixedSeedRNG::::new(pinned), aad, @@ -664,7 +772,11 @@ impl TestFrameworkAEADCipher { ct.extend_from_slice(&buf[..n]); } let mut final_buf = [0u8; FINAL_LEN]; - let (final_len, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + let (final_len, tag) = enc.do_final_out_detached(&mut final_buf).unwrap(); + assert!( + final_len + TAG_LEN <= FINAL_LEN, + "chunk {chunk}: the detached flush must leave FINAL_LEN room for the tag" + ); ct.extend_from_slice(&final_buf[..final_len]); assert_eq!(ct, ct_ref, "chunk {chunk}: streaming must give the one-shot ciphertext"); assert_eq!(tag, tag_ref, "chunk {chunk}: streaming must give the one-shot tag"); @@ -683,47 +795,47 @@ impl TestFrameworkAEADCipher { pt.extend_from_slice(&buf[..n]); } let mut final_buf = [0u8; FINAL_LEN]; - let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + let final_len = dec.do_final_out_detached(&tag, &mut final_buf).unwrap(); pt.extend_from_slice(&final_buf[..final_len]); assert_eq!(pt, msg, "chunk {chunk}: streaming round trip"); } - // too-short output buffers on the streaming `do_update_out` are refused with the required - // length, before any work is done -- on both sides, not just the one-shots above. - if !msg.is_empty() { - let (mut enc, _) = E::do_encrypt_init(&key).unwrap(); - let need = enc.update_out_len(msg.len()); - if need > 0 { - let mut short = vec![0u8; need - 1]; - match enc.do_update_out(msg, &mut short) { - Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { - assert_eq!(n, need) - } - other => panic!("encrypt do_update_out into a short buffer: {other:?}"), - } - } - - let (mut dec, ct) = { - let (mut enc, nonce) = E::do_encrypt_init(&key).unwrap(); - let mut ct = vec![0u8; enc.update_out_len(msg.len())]; - enc.do_update_out(msg, &mut ct).unwrap(); - (D::do_decrypt_init(&key, &nonce).unwrap(), ct) - }; - let need = dec.update_out_len(ct.len()); - if need > 0 { - let mut short = vec![0u8; need - 1]; - match dec.do_update_out(&ct, &mut short) { - Err(SymmetricCipherError::OutputBufferTooSmall(n)) => { - assert_eq!(n, need) - } - other => panic!("decrypt do_update_out into a short buffer: {other:?}"), - } - } - } + // the array-returning finals agree with the `_out` ones the chunked loop above used + let (mut enc, nonce) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(pinned)).unwrap(); + enc.do_update_aad(aad).unwrap(); + let mut ct = vec![0u8; enc.update_out_len(msg.len())]; + let n = enc.do_update_out(msg, &mut ct).unwrap(); + ct.truncate(n); + let (last, last_len, tag) = enc.do_final_detached().unwrap(); + ct.extend_from_slice(&last[..last_len]); + assert_eq!(ct, ct_ref, "do_final_detached must give the one-shot ciphertext"); + assert_eq!(tag, tag_ref, "do_final_detached must give the one-shot tag"); + let mut dec = D::do_decrypt_init(&key, &nonce).unwrap(); + dec.do_update_aad(aad).unwrap(); + let mut pt = vec![0u8; dec.update_out_len(ct.len())]; + let n = dec.do_update_out(&ct, &mut pt).unwrap(); + pt.truncate(n); + let (last, data_len) = dec.do_final_detached(&tag).unwrap(); + pt.extend_from_slice(&last[..data_len]); + assert_eq!(pt, msg, "do_final_detached must round trip"); + let mut wrong_tag = tag; + wrong_tag[0] ^= 0xFF; + let mut dec = D::do_decrypt_init(&key, &nonce).unwrap(); + dec.do_update_aad(aad).unwrap(); + let mut pt = vec![0u8; dec.update_out_len(ct.len())]; + dec.do_update_out(&ct, &mut pt).unwrap(); + assert!( + matches!( + dec.do_final_detached(&wrong_tag), + Err(SymmetricCipherError::AEADTagCheckFailed) + ), + "do_final_detached must check the tag" + ); // an empty AAD is a no-op: it must give exactly what absorbing no AAD at all gives - let mut with_empty = vec![0u8; E::encrypt_out_len(msg.len())]; - let (nonce_empty, len_empty, tag_empty) = E::encrypt_out_rng( + let mut with_empty = vec![0u8; E::encrypt_out_len_detached(msg.len())]; + let (nonce_empty, len_empty, tag_empty) = E::encrypt_out_rng_detached( &key, &mut FixedSeedRNG::::new(pinned), b"", @@ -732,8 +844,8 @@ impl TestFrameworkAEADCipher { ) .unwrap(); with_empty.truncate(len_empty); - let mut without = vec![0u8; E::encrypt_out_len(msg.len())]; - let (nonce_none, len_none, tag_none) = E::encrypt_out_rng( + let mut without = vec![0u8; E::encrypt_out_len_detached(msg.len())]; + let (nonce_none, len_none, tag_none) = E::encrypt_out_rng_detached( &key, &mut FixedSeedRNG::::new(pinned), &[], @@ -746,15 +858,32 @@ impl TestFrameworkAEADCipher { assert_eq!(tag_empty, tag_none, "an empty AAD must be a no-op"); assert_eq!(with_empty, without, "an empty AAD must be a no-op"); + // ...and no AAD at all is what the inherited `SymmetricCipherEncryptor` one-shot gives + let mut plain = vec![0u8; E::encrypt_out_len(msg.len())]; + let (nonce_plain, len_plain) = + E::encrypt_out_rng(&key, &mut FixedSeedRNG::::new(pinned), msg, &mut plain) + .unwrap(); + assert_eq!(nonce_plain, nonce_none); + assert_eq!(&plain[..len_plain - TAG_LEN], &without[..], "no-AAD inline ciphertext"); + assert_eq!(&plain[len_plain - TAG_LEN..len_plain], &tag_none, "no-AAD inline tag"); + // a message with no data at all still authenticates its AAD - let (nonce, _ct_len, tag) = E::encrypt_out(&key, aad, &[], &mut []).unwrap(); - D::decrypt_out(&key, &nonce, aad, &[], &tag, &mut []).unwrap(); - match D::decrypt_out(&key, &nonce, b"different associated data", &[], &tag, &mut []) { + let (nonce, _ct_len, tag) = E::encrypt_out_detached(&key, aad, &[], &mut []).unwrap(); + D::decrypt_out_detached(&key, &nonce, aad, &[], &tag, &mut []).unwrap(); + match D::decrypt_out_detached( + &key, + &nonce, + b"different associated data", + &[], + &tag, + &mut [], + ) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } other => panic!("an empty message must still authenticate its AAD, got {other:?}"), }; - // the AAD phase is over once data has been fed in -- on both sides + // the AAD phase is over once data has been fed in -- on both sides, and on the decrypting + // side even when all of it is still being held back as a possible tag let (mut enc, nonce) = E::do_encrypt_init(&key).unwrap(); let mut ct = vec![0u8; enc.update_out_len(msg.len())]; enc.do_update_out(msg, &mut ct).unwrap(); @@ -766,32 +895,36 @@ impl TestFrameworkAEADCipher { // state: the value is still good for the rest of the flow. enc.do_update_aad(b"").unwrap(); let mut final_buf = [0u8; FINAL_LEN]; - let (final_len, tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + let (final_len, tag) = enc.do_final_out_detached(&mut final_buf).unwrap(); ct.extend_from_slice(&final_buf[..final_len]); let mut dec = D::do_decrypt_init(&key, &nonce).unwrap(); - let mut pt = vec![0u8; dec.update_out_len(ct.len())]; - dec.do_update_out(&ct, &mut pt).unwrap(); + let mut pt = vec![0u8; dec.update_out_len(1)]; + let mut got = dec.do_update_out(&ct[..1], &mut pt).unwrap(); + pt.truncate(got); match dec.do_update_aad(aad) { Err(SymmetricCipherError::StateError(_)) => { /* good */ } other => panic!("AAD after data must be refused, got {other:?}"), }; dec.do_update_aad(b"").unwrap(); + let mut rest = vec![0u8; dec.update_out_len(ct.len() - 1)]; + got = dec.do_update_out(&ct[1..], &mut rest).unwrap(); + pt.extend_from_slice(&rest[..got]); let mut final_buf = [0u8; FINAL_LEN]; - let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + let final_len = dec.do_final_out_detached(&tag, &mut final_buf).unwrap(); pt.extend_from_slice(&final_buf[..final_len]); assert_eq!(&pt[..], msg, "a refused do_update_aad must not disturb the state"); - // tampering: every one of these must fail the tag check, and the one-shot must leave no - // plaintext behind when it does - let mut ct = vec![0u8; E::encrypt_out_len(msg.len())]; - let (nonce, ct_len, tag) = E::encrypt_out(&key, aad, msg, &mut ct).unwrap(); + // tampering: every one of these must fail the tag check, and the one-shots must leave no + // plaintext behind when they do + let mut ct = vec![0u8; E::encrypt_out_len_detached(msg.len())]; + let (nonce, ct_len, tag) = E::encrypt_out_detached(&key, aad, msg, &mut ct).unwrap(); ct.truncate(ct_len); let mut tampered = ct.clone(); tampered[3] ^= 0xFF; - let mut buf = vec![0u8; D::decrypt_out_max_len(tampered.len())]; - match D::decrypt_out(&key, &nonce, aad, &tampered, &tag, &mut buf) { + let mut buf = vec![0u8; D::decrypt_out_max_len_detached(tampered.len())]; + match D::decrypt_out_detached(&key, &nonce, aad, &tampered, &tag, &mut buf) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } other => panic!("a modified ciphertext must fail the tag check, got {other:?}"), }; @@ -800,16 +933,49 @@ impl TestFrameworkAEADCipher { "the one-shot decrypt must zeroize the buffer when the tag check fails" ); + let mut tampered_inline = ct.clone(); + tampered_inline.extend_from_slice(&tag); + tampered_inline[3] ^= 0xFF; + for with_aad in [false, true] { + let mut buf = vec![0u8; D::decrypt_out_max_len(tampered_inline.len())]; + let result = if with_aad { + D::decrypt_out_with_aad(&key, &nonce, aad, &tampered_inline, &mut buf) + } else { + D::decrypt_out(&key, &nonce, &tampered_inline, &mut buf) + }; + // Without the AAD the tag was never going to verify; either way what matters is the + // failure and the zeroized buffer. + match result { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("a modified inline ciphertext must fail, got {other:?}"), + }; + assert!( + buf.iter().all(|&b| b == 0), + "the inline one-shot (aad {with_aad}) must zeroize the buffer on a failed check" + ); + } + match D::decrypt_with_aad(&key, &nonce, aad, &tampered_inline) { + Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } + other => panic!("decrypt_with_aad of a modified ciphertext must fail, got {other:?}"), + }; + let mut wrong_tag = tag; wrong_tag[0] ^= 0xFF; - let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; - match D::decrypt_out(&key, &nonce, aad, &ct, &wrong_tag, &mut buf) { + let mut buf = vec![0u8; D::decrypt_out_max_len_detached(ct.len())]; + match D::decrypt_out_detached(&key, &nonce, aad, &ct, &wrong_tag, &mut buf) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } other => panic!("a modified tag must fail the tag check, got {other:?}"), }; - let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; - match D::decrypt_out(&key, &nonce, b"not the right associated data", &ct, &tag, &mut buf) { + let mut buf = vec![0u8; D::decrypt_out_max_len_detached(ct.len())]; + match D::decrypt_out_detached( + &key, + &nonce, + b"not the right associated data", + &ct, + &tag, + &mut buf, + ) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } other => panic!("a modified AAD must fail the tag check, got {other:?}"), }; @@ -817,8 +983,8 @@ impl TestFrameworkAEADCipher { if NONCE_LEN > 0 { let mut wrong_nonce = nonce; wrong_nonce[0] ^= 0xFF; - let mut buf = vec![0u8; D::decrypt_out_max_len(ct.len())]; - match D::decrypt_out(&key, &wrong_nonce, aad, &ct, &tag, &mut buf) { + let mut buf = vec![0u8; D::decrypt_out_max_len_detached(ct.len())]; + match D::decrypt_out_detached(&key, &wrong_nonce, aad, &ct, &tag, &mut buf) { Err(SymmetricCipherError::AEADTagCheckFailed) => { /* good */ } other => panic!("a modified nonce must fail the tag check, got {other:?}"), }; @@ -829,108 +995,61 @@ impl TestFrameworkAEADCipher { assert_ne!(nonce1, nonce2); } - // error case: KeyMaterial of wrong type - let mac_key = - KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) - .unwrap(); - match E::do_encrypt_init(&mac_key) { - Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } - _ => panic!("Unexpected error"), - }; - match D::do_decrypt_init(&mac_key, &nonce) { - Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } - _ => panic!("Unexpected error"), - }; - - // error case: security strengths too weak and too strong - let mut key = KeyMaterial::::from_bytes_as_type( - &DUMMY_SEED[..KEY_LEN], - KeyType::SymmetricCipherKey, - ) - .unwrap(); - let security_strengths = [ - SecurityStrength::None, - SecurityStrength::_112bit, - SecurityStrength::_128bit, - SecurityStrength::_192bit, - SecurityStrength::_256bit, - ]; - let mut strengths_tested = 0; - for ss in security_strengths.iter() { - // See the note in `test_plain_one_shots`: a KEY_LEN-byte key cannot be tagged above - // `from_bytes(KEY_LEN)` even inside `do_hazardous_operations`, so skip the strengths - // this key cannot carry. - if ss > &SecurityStrength::from_bytes(KEY_LEN) { - continue; - } - - // Tag the key at an arbitrary strength for the purpose of this test. - do_hazardous_operations(&mut key, |key| key.set_security_strength(*ss)).unwrap(); - strengths_tested += 1; - - // Both directions must enforce the same policy. - let check_strength = |result: Result<(), SymmetricCipherError>| match result { - Ok(_) => { - if ss >= &E::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should have been a strong enough key"); - } - } - Err(SymmetricCipherError::KeyMaterialError(_)) => { - if ss < &E::MAX_SECURITY_STRENGTH { /* good */ - } else { - panic!("Should not have accepted a key weaker than algorithm"); - } - } - _ => panic!("Unexpected error"), - }; - check_strength(E::do_encrypt_init(&key).map(|_| ())); - check_strength(D::do_decrypt_init(&key, &nonce).map(|_| ())); - } - assert!(strengths_tested > 0, "strength sweep must not be vacuous"); + // The key-type and security-strength checks on `do_encrypt_init` / `do_decrypt_init` are + // covered by the `TestFrameworkSymmetricCipher` suite run above. } /// Pins that a *genuinely buffering* [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] pair's /// `update_out_len` is honoured through every chunking, against a toy built to hold back up to - /// three bytes at a time before releasing them -- the property - /// [`Self::test_encryptor_decryptor`] cannot pin on its own, since a caller-supplied `E`/`D` - /// might never buffer (Ascon-AEAD128 never does). Modelled on the toy permutations - /// `crypto/modes/tests/common/mod.rs` uses for the equivalent block-cipher property. + /// three bytes at a time before releasing them -- more than the tag the decryptor has to hold + /// back anyway -- the property [`Self::test_encryptor_decryptor`] cannot pin on its own, since + /// a caller-supplied `E`/`D` might hold back nothing but the tag (Ascon-AEAD128 holds back + /// nothing else). Modelled on the toy permutations `crypto/modes/tests/common/mod.rs` uses for + /// the equivalent block-cipher property. /// /// The toy's "ciphertext" is the plaintext with a per-byte counter XORed in, released three - /// bytes behind what it has consumed (so `update_out_len(n)` is `0` for the first two bytes of - /// any run and `n` thereafter, once three bytes are already buffered); its "tag" is a length - /// check. Not remotely a real AEAD -- it exists solely to make holding data back observable. + /// bytes behind what it has consumed when encrypting and three plus `TAG_LEN` when decrypting; + /// its "tag" is a length check. Not remotely a real AEAD -- it exists solely to make holding + /// data back observable. pub fn test_buffering_toy(&self) { use bouncycastle_core::errors::SymmetricCipherError; use bouncycastle_core::key_material::{KeyMaterial, KeyType}; use bouncycastle_core::traits::{ AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, + SymmetricCipherDecryptor, SymmetricCipherEncryptor, }; const HOLD_BACK: usize = 3; const KEY_LEN: usize = 4; const NONCE_LEN: usize = 4; const TAG_LEN: usize = 1; + // What either side's final call can produce: the encryptor's held-back bytes plus the tag + // after them, or everything the decryptor held back. + const FINAL_LEN: usize = HOLD_BACK + TAG_LEN; struct Buffered { + hold: usize, pos: u8, - held: [u8; HOLD_BACK], + held: [u8; FINAL_LEN], held_len: usize, len_seen: usize, } impl Buffered { - fn new() -> Self { - Self { pos: 0, held: [0u8; HOLD_BACK], held_len: 0, len_seen: 0 } + fn new(hold: usize) -> Self { + Self { hold, pos: 0, held: [0u8; FINAL_LEN], held_len: 0, len_seen: 0 } + } + + fn update_out_len(&self, input_len: usize) -> usize { + (self.held_len + input_len).saturating_sub(self.hold) } - /// Feeds `input` in, holding back the last `HOLD_BACK` bytes and releasing (XORed - /// with a running counter) everything older than that into `output`. + /// Feeds `input` in, holding back the last `hold` bytes and releasing (XORed with a + /// running counter) everything older than that into `output`. fn update_out(&mut self, input: &[u8], output: &mut [u8]) -> usize { self.len_seen += input.len(); let total = self.held_len + input.len(); - let releasable = total.saturating_sub(HOLD_BACK); + let releasable = total.saturating_sub(self.hold); let from_held = self.held_len.min(releasable); let from_new = releasable - from_held; for (i, b) in self.held[..from_held].iter().enumerate() { @@ -941,11 +1060,11 @@ impl TestFrameworkAEADCipher { output[from_held + i] = *b ^ self.pos; self.pos = self.pos.wrapping_add(1); } - // The amount kept is `total - releasable`, which is `HOLD_BACK` once `total` - // reaches it but only `total` itself before that -- so the tail of `new_held` - // actually in use is `new_len`, not always the full array up to `HOLD_BACK`. + // The amount kept is `total - releasable`, which is `hold` once `total` reaches it + // but only `total` itself before that -- so the tail of `new_held` actually in use + // is `new_len`, not always the full `hold`. let new_len = total - releasable; - let mut new_held = [0u8; HOLD_BACK]; + let mut new_held = [0u8; FINAL_LEN]; let kept_from_held = self.held_len - from_held; new_held[..kept_from_held].copy_from_slice(&self.held[from_held..self.held_len]); new_held[kept_from_held..new_len].copy_from_slice(&input[from_new..]); @@ -954,14 +1073,19 @@ impl TestFrameworkAEADCipher { releasable } - fn finish(self, output: &mut [u8]) -> usize { - for (i, b) in self.held[..self.held_len].iter().enumerate() { + /// Releases the first `n` held-back bytes into `output`. + fn finish(&mut self, n: usize, output: &mut [u8]) { + for (i, b) in self.held[..n].iter().enumerate() { output[i] = *b ^ self.pos; + self.pos = self.pos.wrapping_add(1); } - self.held_len } } + fn toy_tag(data_len: usize) -> [u8; TAG_LEN] { + [(data_len % 256) as u8; TAG_LEN] + } + struct Enc(Buffered); struct Dec(Buffered); @@ -974,11 +1098,11 @@ impl TestFrameworkAEADCipher { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; } - impl AEADCipherEncryptor for Enc { + impl SymmetricCipherEncryptor for Enc { fn do_encrypt_init( _key: &KeyMaterial, ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { - Ok((Self(Buffered::new()), [0u8; NONCE_LEN])) + Ok((Self(Buffered::new(HOLD_BACK)), [0u8; NONCE_LEN])) } fn do_encrypt_init_rng( key: &KeyMaterial, @@ -986,11 +1110,8 @@ impl TestFrameworkAEADCipher { ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { Self::do_encrypt_init(key) } - fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { - Ok(()) - } fn update_out_len(&self, input_len: usize) -> usize { - (self.0.held_len + input_len).saturating_sub(HOLD_BACK) + self.0.update_out_len(input_len) } fn do_update_out( &mut self, @@ -999,28 +1120,40 @@ impl TestFrameworkAEADCipher { ) -> Result { Ok(self.0.update_out(plaintext, ciphertext)) } - fn do_encrypt_final( - self, - output: &mut [u8; HOLD_BACK], + fn do_final(self) -> Result<([u8; FINAL_LEN], usize), SymmetricCipherError> { + let mut out = [0u8; FINAL_LEN]; + let (n, tag) = self.do_final_out_detached(&mut out)?; + out[n..n + TAG_LEN].copy_from_slice(&tag); + Ok((out, n + TAG_LEN)) + } + fn encrypt_out_len(plaintext_len: usize) -> usize { + plaintext_len + TAG_LEN + } + } + + impl AEADCipherEncryptor for Enc { + fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { + Ok(()) + } + fn do_final_out_detached( + mut self, + ciphertext: &mut [u8; FINAL_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { - let len_seen = self.0.len_seen; - let n = self.0.finish(output); - Ok((n, [(len_seen % 256) as u8; TAG_LEN])) + let n = self.0.held_len; + self.0.finish(n, ciphertext); + Ok((n, toy_tag(self.0.len_seen))) } } - impl AEADCipherDecryptor for Dec { + impl SymmetricCipherDecryptor for Dec { fn do_decrypt_init( _key: &KeyMaterial, _nonce: &[u8; NONCE_LEN], ) -> Result { - Ok(Self(Buffered::new())) - } - fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { - Ok(()) + Ok(Self(Buffered::new(FINAL_LEN))) } fn update_out_len(&self, input_len: usize) -> usize { - (self.0.held_len + input_len).saturating_sub(HOLD_BACK) + self.0.update_out_len(input_len) } fn do_update_out( &mut self, @@ -1029,14 +1162,35 @@ impl TestFrameworkAEADCipher { ) -> Result { Ok(self.0.update_out(ciphertext, plaintext)) } - fn do_decrypt_final( - self, + /// The last `TAG_LEN` held-back bytes are the tag, the rest ciphertext. + fn do_final(mut self) -> Result<([u8; FINAL_LEN], usize), SymmetricCipherError> { + let Some(n) = self.0.held_len.checked_sub(TAG_LEN) else { + return Err(SymmetricCipherError::DecryptionFailed); + }; + let mut out = [0u8; FINAL_LEN]; + self.0.finish(n, &mut out); + if self.0.held[n..n + TAG_LEN] != toy_tag(self.0.len_seen - TAG_LEN) { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok((out, n)) + } + fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + ciphertext_len.saturating_sub(TAG_LEN) + } + } + + impl AEADCipherDecryptor for Dec { + fn do_update_aad(&mut self, _aad: &[u8]) -> Result<(), SymmetricCipherError> { + Ok(()) + } + fn do_final_out_detached( + mut self, tag: &[u8; TAG_LEN], - output: &mut [u8; HOLD_BACK], + plaintext: &mut [u8; FINAL_LEN], ) -> Result { - let len_seen = self.0.len_seen; - let n = self.0.finish(output); - if *tag != [(len_seen % 256) as u8; TAG_LEN] { + let n = self.0.held_len; + self.0.finish(n, plaintext); + if *tag != toy_tag(self.0.len_seen) { return Err(SymmetricCipherError::AEADTagCheckFailed); } Ok(n) @@ -1049,14 +1203,13 @@ impl TestFrameworkAEADCipher { ) .unwrap(); - for len in 0..=(3 * HOLD_BACK + 5) { + for len in 0..=(3 * FINAL_LEN + 5) { let msg = &DUMMY_SEED[..len]; - let mut ct = vec![0u8; len + HOLD_BACK]; - let (nonce, ct_len, tag) = Enc::encrypt_out(&key, b"", msg, &mut ct).unwrap(); - ct.truncate(ct_len); + let mut ct = vec![0u8; len]; + let (nonce, ct_len, tag) = Enc::encrypt_out_detached(&key, b"", msg, &mut ct).unwrap(); assert_eq!(ct_len, len, "the toy never expands the data, only the finalizer flushes"); - for chunk in [1usize, 2, 3, HOLD_BACK, HOLD_BACK + 1, len.max(1)] { + for chunk in [1usize, 2, 3, HOLD_BACK, FINAL_LEN, FINAL_LEN + 1, len.max(1)] { let (mut enc, _) = Enc::do_encrypt_init(&key).unwrap(); let mut chunked = Vec::new(); for piece in msg.chunks(chunk) { @@ -1066,8 +1219,8 @@ impl TestFrameworkAEADCipher { assert_eq!(n, expect, "len {len} chunk {chunk}: update_out_len must be exact"); chunked.extend_from_slice(&buf[..n]); } - let mut final_buf = [0u8; HOLD_BACK]; - let (final_len, chunked_tag) = enc.do_encrypt_final(&mut final_buf).unwrap(); + let mut final_buf = [0u8; FINAL_LEN]; + let (final_len, chunked_tag) = enc.do_final_out_detached(&mut final_buf).unwrap(); chunked.extend_from_slice(&final_buf[..final_len]); assert_eq!(chunked, ct, "len {len} chunk {chunk}: chunking must not be visible"); assert_eq!( @@ -1075,6 +1228,8 @@ impl TestFrameworkAEADCipher { "len {len} chunk {chunk}: tag must not depend on chunking" ); + // detached: the decryptor releases what it held back as a possible tag in + // `do_final_out_detached`, alongside what it held back of its own accord let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); let mut pt = Vec::new(); for piece in ct.chunks(chunk) { @@ -1084,55 +1239,85 @@ impl TestFrameworkAEADCipher { assert_eq!(n, expect, "len {len} chunk {chunk}: update_out_len must be exact"); pt.extend_from_slice(&buf[..n]); } - let mut final_buf = [0u8; HOLD_BACK]; - let final_len = dec.do_decrypt_final(&tag, &mut final_buf).unwrap(); + let mut final_buf = [0u8; FINAL_LEN]; + let final_len = dec.do_final_out_detached(&tag, &mut final_buf).unwrap(); pt.extend_from_slice(&final_buf[..final_len]); - assert_eq!(pt, msg, "len {len} chunk {chunk}: round trip"); + assert_eq!(pt, msg, "len {len} chunk {chunk}: detached round trip"); + + // inline: the same stream with the tag on the end, chunked the same way + let mut inline = ct.clone(); + inline.extend_from_slice(&tag); + let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); + let mut pt = Vec::new(); + for piece in inline.chunks(chunk) { + let expect = dec.update_out_len(piece.len()); + let mut buf = vec![0u8; expect]; + let n = dec.do_update_out(piece, &mut buf).unwrap(); + assert_eq!(n, expect, "len {len} chunk {chunk}: update_out_len must be exact"); + pt.extend_from_slice(&buf[..n]); + } + let (last, data_len) = dec.do_final().unwrap(); + pt.extend_from_slice(&last[..data_len]); + assert_eq!(pt, msg, "len {len} chunk {chunk}: inline round trip"); } // The inline `ciphertext || tag` layout, which is where a buffering cipher makes - // `tagged_do_aead_encrypt_final` do two things at once: flush the held-back bytes and - // then append the tag after them. + // `do_final` do two things at once: flush the held-back bytes and then append the tag + // after them. let (mut enc, nonce) = Enc::do_encrypt_init(&key).unwrap(); - // `+ HOLD_BACK`: see the same sizing in `test_encryptor_decryptor`. - let mut inline = vec![0u8; Enc::tagged_encrypt_out_len(len) + HOLD_BACK]; - let mut written = enc.do_update_out(msg, &mut inline).unwrap(); + let mut inline = vec![0u8; enc.update_out_len(len)]; + let written = enc.do_update_out(msg, &mut inline).unwrap(); assert!(written < len || len == 0, "len {len}: the toy must be holding something back"); - written += enc.tagged_do_aead_encrypt_final(&mut inline[written..]).unwrap(); + let (last, last_len) = enc.do_final().unwrap(); + inline.extend_from_slice(&last[..last_len]); assert_eq!( - written, + inline.len(), len + TAG_LEN, "len {len}: inline layout is the message plus a tag" ); - // Stop a few bytes short of the tag as well, so the finalizer has real ciphertext to - // decrypt and not just a tag to check, and give it a buffer of exactly the length it - // asks for: that is what makes `update_out_len(..) + FINAL_LEN` observable, since with - // a generous buffer any arithmetic there would do. - let held_back = (TAG_LEN + 4).min(written); - let body = written - held_back; - let mut dec = Dec::do_decrypt_init(&key, &nonce).unwrap(); - let mut pt = vec![0u8; written + HOLD_BACK]; - let mut got = dec.do_update_out(&inline[..body], &mut pt).unwrap(); - let need = dec.update_out_len(held_back - TAG_LEN) + HOLD_BACK; - got += dec - .tagged_do_aead_decrypt_final(&inline[body..written], &mut pt[got..got + need]) - .unwrap(); - assert_eq!(&pt[..got], msg, "len {len}: inline streaming round trip"); - - let mut one = vec![0u8; Enc::tagged_encrypt_out_len(len)]; - let (one_nonce, one_len) = Enc::tagged_encrypt(&key, b"", msg, &mut one).unwrap(); - assert_eq!(&one[..one_len], &inline[..written], "len {len}: one-shot must agree"); - let mut back = vec![0u8; Dec::tagged_decrypt_out_max_len(one_len) + HOLD_BACK]; + let mut one = vec![0u8; Enc::encrypt_out_len(len)]; + let (one_nonce, one_len) = Enc::encrypt_out_with_aad(&key, b"", msg, &mut one).unwrap(); + assert_eq!(&one[..one_len], &inline[..], "len {len}: one-shot must agree"); + assert_eq!(one_nonce, nonce); + // Exactly the buffer it asks for: that is what makes the `+ data_len` arithmetic in + // the one-shot observable, since with a generous buffer any arithmetic there would do. + let mut back = vec![0u8; Dec::decrypt_out_max_len(one_len)]; let back_len = - Dec::tagged_decrypt(&key, &one_nonce, b"", &one[..one_len], &mut back).unwrap(); + Dec::decrypt_out_with_aad(&key, &one_nonce, b"", &one[..one_len], &mut back) + .unwrap(); assert_eq!(&back[..back_len], msg, "len {len}: inline one-shot round trip"); + // Every other one-shot over the toy too: its final calls flush real data, which is + // what makes the `written + final_len` arithmetic in each of them observable. + let mut ct_rng = vec![0u8; len]; + let (_, n_rng, tag_rng) = Enc::encrypt_out_rng_detached( + &key, + &mut FixedSeedRNG::::new([0u8; NONCE_LEN]), + b"", + msg, + &mut ct_rng, + ) + .unwrap(); + assert_eq!(&ct_rng[..n_rng], &ct[..], "len {len}: encrypt_out_rng_detached"); + assert_eq!(tag_rng, tag, "len {len}: encrypt_out_rng_detached tag"); + let mut back = vec![0u8; len]; + let back_len = + Dec::decrypt_out_detached(&key, &nonce, b"", &ct, &tag, &mut back).unwrap(); + assert_eq!(&back[..back_len], msg, "len {len}: decrypt_out_detached"); + let mut plain = vec![0u8; Enc::encrypt_out_len(len)]; + let (plain_nonce, plain_len) = Enc::encrypt_out(&key, msg, &mut plain).unwrap(); + assert_eq!(&plain[..plain_len], &inline[..], "len {len}: encrypt_out"); + let mut back = vec![0u8; Dec::decrypt_out_max_len(plain_len)]; + let back_len = + Dec::decrypt_out(&key, &plain_nonce, &plain[..plain_len], &mut back).unwrap(); + assert_eq!(&back[..back_len], msg, "len {len}: decrypt_out"); + // For any length past the hold-back window, at least one prefix of the input must be // held back rather than released immediately -- the property this whole test exists - // to pin. (For `len < HOLD_BACK` nothing is ever releasable until `do_encrypt_final`, - // which is also correct but does not exercise `do_update_out` returning less than it - // was given.) + // to pin. (For `len < HOLD_BACK` nothing is ever releasable until `do_final`, which is + // also correct but does not exercise `do_update_out` returning less than it was + // given.) if len > HOLD_BACK { let (mut enc, _) = Enc::do_encrypt_init(&key).unwrap(); let first = &msg[..1]; diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 7bac7394..f233d8e7 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -12,7 +12,7 @@ use crate::key_material::KeyMaterial; use crate::key_material::KeyType; // end of imports needed for docs -/// What the allocating one-shot [`AEADCipherEncryptor::encrypt`] hands back: the nonce it +/// What the allocating one-shot [`AEADCipherEncryptor::encrypt_detached`] hands back: the nonce it /// generated, the ciphertext, and the tag, in that order. A named type because the bare triple is /// past what is readable inline (clippy's `type_complexity`). #[cfg(feature = "std")] @@ -20,143 +20,95 @@ pub type AEADEncrypted = ([u8; NONCE_LEN], Vec, [u8; TAG_LEN]); /// The decryption half of an AEAD cipher's streaming API; see [`AEADCipherEncryptor`], whose notes -/// on the AAD phase, buffering, and the `Result` all apply here too. +/// on the AAD phase, the two tag layouts, buffering, and the `Result` all apply here too. /// -/// # The plaintext is not authenticated until `do_decrypt_final` returns `Ok` +/// This extends [`SymmetricCipherDecryptor`], whose methods are the AEAD with no associated data +/// and the tag inline -- the last `TAG_LEN` bytes of the ciphertext. That is why a decryptor has +/// to hold back the last `TAG_LEN` bytes it has seen at all times: the tag is only identifiable +/// once the stream ends, and [`SymmetricCipherDecryptor::do_update_out`] cannot know which final +/// method will be called. With the tag detached those held-back bytes turn out to be ciphertext, +/// and [`do_final_out_detached`](Self::do_final_out_detached) decrypts them; with it inline, +/// [`SymmetricCipherDecryptor::do_final`] checks them as the tag. So `FINAL_LEN` is at least +/// `TAG_LEN`, plus whatever else the cipher holds back of its own accord. +/// +/// # The plaintext is not authenticated until the final call returns `Ok` /// /// This is the one thing a streaming AEAD API cannot hide from its caller. -/// [`do_update_out`](Self::do_update_out) releases plaintext as soon as it can, long before there -/// is a tag to check it against, so a caller that *uses* those bytes before -/// [`do_decrypt_final`](Self::do_decrypt_final) has returned `Ok` is acting on unauthenticated -/// plaintext -- bytes an attacker may have chosen. Preventing exactly that is what the tag is for. -/// A streaming caller must therefore treat everything `do_update_out` produces as untrusted until -/// the final call succeeds, and scrub it if it does not. -/// -/// The one-shot [`decrypt`](Self::decrypt) has no such caveat: it owns the whole message, so it -/// zeroizes the buffer itself before returning the error. +/// [`SymmetricCipherDecryptor::do_update_out`] releases plaintext as soon as it can, long before +/// there is a tag to check it against, so a caller that *uses* those bytes before +/// [`do_final_out_detached`](Self::do_final_out_detached) or [`SymmetricCipherDecryptor::do_final`] has +/// returned `Ok` is acting on unauthenticated plaintext -- bytes an attacker may have chosen. +/// Preventing exactly that is what the tag is for. A streaming caller must therefore treat +/// everything `do_update_out` produces as untrusted until the final call succeeds, and scrub it if +/// it does not. +/// +/// The one-shots -- [`decrypt_out_detached`](Self::decrypt_out_detached), +/// [`decrypt_out_with_aad`](Self::decrypt_out_with_aad) and [`SymmetricCipherDecryptor::decrypt_out`] -- have +/// no such caveat: each owns the whole message, so it zeroizes the buffer itself before returning +/// the error. pub trait AEADCipherDecryptor< const KEY_LEN: usize, const NONCE_LEN: usize, const TAG_LEN: usize, const FINAL_LEN: usize, ->: Algorithm + Sized +>: SymmetricCipherDecryptor { - /// Begins a streaming decryption flow from the nonce returned by - /// [`AEADCipherEncryptor::do_encrypt_init`]. - /// - /// # Errors - /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose - /// security strength is below [`Algorithm::MAX_SECURITY_STRENGTH`], both as a - /// [`SymmetricCipherError::KeyMaterialError`]. - fn do_decrypt_init( - key: &KeyMaterial, - nonce: &[u8; NONCE_LEN], - ) -> Result; - /// Absorbs additional authenticated data; see [`AEADCipherEncryptor::do_update_aad`] for the /// rules, which are the same on both sides. The concatenation of what a decryptor absorbs must /// be byte-for-byte the concatenation the encryptor absorbed, or the tag check fails. /// /// # Errors /// [`SymmetricCipherError::StateError`] if called with a non-empty `aad` after - /// [`do_update_out`](Self::do_update_out). + /// [`SymmetricCipherDecryptor::do_update_out`]. fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError>; - /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if - /// given `input_len` more bytes of ciphertext. Depends on what is already buffered; identically - /// `input_len` for a cipher that never holds anything back, such as Ascon-AEAD128. - fn update_out_len(&self, input_len: usize) -> usize; - - /// Streaming: consumes `ciphertext`, writing every plaintext byte that can be released so far - /// into `plaintext` and buffering the rest. Returns the number of bytes written, which is - /// exactly [`update_out_len`](Self::update_out_len) of `ciphertext.len()`. - /// - /// The bytes this writes are *not* yet authenticated; see the trait docs. A decryptor may have - /// to hold back the tail of what it has seen -- a block-oriented cipher's partial final block, - /// or the bytes that might turn out to be an inline tag -- so a sequence of calls releases data - /// later than the corresponding encryptor produced it, but the concatenation of everything - /// released, in any chunking, plus the data part of - /// [`do_decrypt_final`](Self::do_decrypt_final), is the plaintext. - /// - /// # Errors - /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is shorter than - /// [`update_out_len`](Self::update_out_len), carrying the required length. Nothing is - /// consumed in that case. - fn do_update_out( - &mut self, - ciphertext: &[u8], - plaintext: &mut [u8], - ) -> Result; - - /// Finishes the decryption, consuming the decryptor: flushes whatever ciphertext was held back - /// into `output`, computes the tag over the AAD and ciphertext it has seen, and compares it - /// against `tag`. Returns how many leading bytes of `output` are plaintext; the remainder is - /// not data and must not be used. `Ok` is the only thing that makes those bytes -- or anything - /// already released by [`do_update_out`](Self::do_update_out) -- trustworthy. + /// Finishes the decryption with the tag detached, consuming the decryptor: decrypts whatever + /// ciphertext was held back into `plaintext` -- including the last `TAG_LEN` bytes, which with + /// the tag carried separately are ciphertext like the rest -- computes the tag over the AAD and + /// ciphertext it has seen, and compares it against `tag`. Returns how many leading bytes of + /// `plaintext` are data; the remainder is not data and must not be used. `Ok` is the only + /// thing that makes those bytes -- or anything already released by + /// [`SymmetricCipherDecryptor::do_update_out`] -- trustworthy. /// /// # Errors /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. Implementors must /// compare in constant time, and the caller learns only that the check failed. - fn do_decrypt_final( + fn do_final_out_detached( self, tag: &[u8; TAG_LEN], - output: &mut [u8; FINAL_LEN], + plaintext: &mut [u8; FINAL_LEN], ) -> Result; - /// Streaming finalization for the inline `ciphertext || tag` layout: `tail` is the end of the - /// ciphertext stream -- whatever ciphertext has not been given to - /// [`do_update_out`](Self::do_update_out) yet, followed by the `TAG_LEN` tag bytes. The - /// ciphertext part is decrypted into `plaintext`, and the trailing bytes are then checked as - /// the tag, exactly as [`do_decrypt_final`](Self::do_decrypt_final) checks one handed to it - /// separately. Returns the number of plaintext bytes written here. - /// - /// The tag is only identifiable once the stream ends, so a caller streaming this layout has to - /// hold back the last `TAG_LEN` bytes it has seen at all times and pass them in here; nothing - /// earlier in the stream can tell it which bytes they will be. - /// - /// `plaintext` needs `update_out_len(tail.len() - TAG_LEN) + FINAL_LEN` bytes. As with - /// [`do_update_out`](Self::do_update_out), nothing written here is authenticated until the - /// call returns `Ok`. + /// As [`do_final_out_detached`](Self::do_final_out_detached), returning the final buffer + /// together with the number of leading bytes of it that are plaintext, the shape of + /// [`SymmetricCipherDecryptor::do_final`]. The two are provided the other way round from the + /// base trait's pair -- the `_out` form is the one an implementor writes -- because that is the + /// form that lets an implementor decrypt the held-back bytes straight into the caller's buffer. + /// On failure no buffer is returned, so nothing unauthenticated is left behind by this call. /// /// # Errors - /// [`SymmetricCipherError::DecryptionFailed`] if `tail` is shorter than `TAG_LEN`, i.e. the - /// stream ended before a whole tag had been seen; - /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is too short, checked - /// before any work is done; [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not - /// verify. - fn tagged_do_aead_decrypt_final( - mut self, - tail: &[u8], - plaintext: &mut [u8], - ) -> Result { - if tail.len() < TAG_LEN { - return Err(SymmetricCipherError::DecryptionFailed); - } - let (ciphertext, tag) = tail.split_at(tail.len() - TAG_LEN); - let needed = self.update_out_len(ciphertext.len()) + FINAL_LEN; - if plaintext.len() < needed { - return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); - } - // infallible: `split_at` above leaves exactly `TAG_LEN` bytes in `tag`. - let tag: &[u8; TAG_LEN] = tag.try_into().unwrap(); - let written = self.do_update_out(ciphertext, plaintext)?; - let mut final_buf = [0u8; FINAL_LEN]; - let final_len = self.do_decrypt_final(tag, &mut final_buf)?; - plaintext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); - Ok(written + final_len) + /// As [`do_final_out_detached`](Self::do_final_out_detached). + fn do_final_detached( + self, + tag: &[u8; TAG_LEN], + ) -> Result<([u8; FINAL_LEN], usize), SymmetricCipherError> { + let mut plaintext = [0u8; FINAL_LEN]; + let data_len = self.do_final_out_detached(tag, &mut plaintext)?; + Ok((plaintext, data_len)) } - /// An upper bound on the plaintext recovered from `ciphertext_len` bytes of ciphertext, i.e. - /// the buffer [`decrypt_out`](Self::decrypt_out) requires. The default returns `ciphertext_len` - /// itself, which is exact for every conformant AEAD: unlike a padding scheme, an AEAD never - /// expands or shrinks the data it is given, only adds the separate `tag`. - fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + /// An upper bound on the plaintext recovered from `ciphertext_len` bytes of ciphertext with + /// the tag detached, i.e. the buffer [`decrypt_out_detached`](Self::decrypt_out_detached) + /// requires. The default returns `ciphertext_len` itself, which is exact for every conformant + /// AEAD: unlike a padding scheme, an AEAD never expands or shrinks the data it is given, only + /// adds the separate `tag`. + fn decrypt_out_max_len_detached(ciphertext_len: usize) -> usize { ciphertext_len } - /// One-shot: decrypts `ciphertext` into `plaintext`, which needs - /// [`decrypt_out_max_len`](Self::decrypt_out_max_len) bytes, under `nonce` and `aad`, and - /// checks `tag`. Returns the number of plaintext bytes written. + /// One-shot with the tag detached: decrypts `ciphertext` into `plaintext`, which needs + /// [`decrypt_out_max_len_detached`](Self::decrypt_out_max_len_detached) bytes, under `nonce` + /// and `aad`, and checks `tag`. Returns the number of plaintext bytes written. /// /// Unlike the streaming methods this releases nothing unauthenticated: on failure `plaintext` /// is zeroized before the error is returned, so a caller who ignores the `Result` is left with @@ -165,8 +117,8 @@ pub trait AEADCipherDecryptor< /// # Errors /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is too short, checked /// before any work is done; otherwise whatever the streaming methods return, including - /// [`do_decrypt_final`](Self::do_decrypt_final)'s. - fn decrypt_out( + /// [`do_final_out_detached`](Self::do_final_out_detached)'s. + fn decrypt_out_detached( key: &KeyMaterial, nonce: &[u8; NONCE_LEN], aad: &[u8], @@ -174,7 +126,7 @@ pub trait AEADCipherDecryptor< tag: &[u8; TAG_LEN], plaintext: &mut [u8], ) -> Result { - let needed = Self::decrypt_out_max_len(ciphertext.len()); + let needed = Self::decrypt_out_max_len_detached(ciphertext.len()); if plaintext.len() < needed { return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); } @@ -182,10 +134,10 @@ pub trait AEADCipherDecryptor< dec.do_update_aad(aad)?; let written = dec.do_update_out(ciphertext, plaintext)?; let mut final_buf = [0u8; FINAL_LEN]; - match dec.do_decrypt_final(tag, &mut final_buf) { + match dec.do_final_out_detached(tag, &mut final_buf) { Ok(final_len) => { - // Implementors with FINAL_LEN > 0 must override `decrypt_out_max_len` so this fits - // in `plaintext[..needed]`. + // Everything held back comes out of `do_final_out_detached`, so `written + final_len` + // is the ciphertext length, which `decrypt_out_max_len_detached` bounds. plaintext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok(written + final_len) } @@ -202,77 +154,117 @@ pub trait AEADCipherDecryptor< } } - /// The plaintext buffer [`tagged_decrypt`](Self::tagged_decrypt) requires for `ciphertext_len` - /// bytes of `ciphertext || tag`: what the ciphertext alone needs, the tag being no part of the - /// plaintext. - fn tagged_decrypt_out_max_len(ciphertext_len: usize) -> usize { - Self::decrypt_out_max_len(ciphertext_len.saturating_sub(TAG_LEN)) - } - - /// One-shot over the inline `ciphertext || tag` layout: takes the trailing `TAG_LEN` bytes of - /// `ciphertext` as the tag, and is otherwise exactly [`decrypt_out`](Self::decrypt_out), - /// including zeroizing `plaintext` when the tag does not verify. `plaintext` needs - /// [`tagged_decrypt_out_max_len`](Self::tagged_decrypt_out_max_len) bytes. + /// One-shot over the inline `ciphertext || tag` layout with associated data: the trailing + /// `TAG_LEN` bytes of `ciphertext` are the tag. This is [`SymmetricCipherDecryptor::decrypt_out`] + /// with an `aad`, and needs the same + /// [`decrypt_out_max_len`](SymmetricCipherDecryptor::decrypt_out_max_len) bytes of + /// `plaintext`. As with every AEAD one-shot, `plaintext` is zeroized when the tag does not + /// verify. /// /// # Errors - /// [`SymmetricCipherError::DecryptionFailed`] if `ciphertext` is shorter than the tag it is - /// supposed to end with; otherwise as [`decrypt_out`](Self::decrypt_out). - fn tagged_decrypt( + /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is too short, checked + /// before any work is done; [`SymmetricCipherError::DecryptionFailed`] if `ciphertext` is + /// shorter than the tag it is supposed to end with; + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. + fn decrypt_out_with_aad( key: &KeyMaterial, nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8], plaintext: &mut [u8], ) -> Result { - if ciphertext.len() < TAG_LEN { - return Err(SymmetricCipherError::DecryptionFailed); + let needed = Self::decrypt_out_max_len(ciphertext.len()); + if plaintext.len() < needed { + return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); + } + let mut dec = Self::do_decrypt_init(key, nonce)?; + dec.do_update_aad(aad)?; + let written = dec.do_update_out(ciphertext, plaintext)?; + match dec.do_final() { + Ok((last, data_len)) => { + // `decrypt_out_max_len` bounds `written + data_len`, so this fits in + // `plaintext[..needed]`. + plaintext[written..written + data_len].copy_from_slice(&last[..data_len]); + Ok(written + data_len) + } + Err(e) => { + // As in `decrypt_out_detached`. + plaintext[..written].fill(0); + Err(e) + } } - let (body, tag) = ciphertext.split_at(ciphertext.len() - TAG_LEN); - // infallible: `split_at` above leaves exactly `TAG_LEN` bytes in `tag`. - let tag: &[u8; TAG_LEN] = tag.try_into().unwrap(); - Self::decrypt_out(key, nonce, aad, body, tag, plaintext) } #[cfg(feature = "std")] - /// One-shot, allocating: as [`decrypt_out`](Self::decrypt_out), returning the plaintext as a + /// One-shot, allocating, with the tag detached: as + /// [`decrypt_out_detached`](Self::decrypt_out_detached), returning the plaintext as a /// `Vec` of exactly the recovered length. Only available with the `std` feature. - fn decrypt( + fn decrypt_detached( key: &KeyMaterial, nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8], tag: &[u8; TAG_LEN], + ) -> Result, SymmetricCipherError> { + let mut plaintext = vec![0u8; Self::decrypt_out_max_len_detached(ciphertext.len())]; + let written = Self::decrypt_out_detached(key, nonce, aad, ciphertext, tag, &mut plaintext)?; + plaintext.truncate(written); + Ok(plaintext) + } + + #[cfg(feature = "std")] + /// One-shot, allocating, over the inline `ciphertext || tag` layout with associated data: as + /// [`decrypt_out_with_aad`](Self::decrypt_out_with_aad), returning the plaintext as a + /// `Vec` of exactly the recovered length. This is [`SymmetricCipherDecryptor::decrypt`] + /// with an `aad`. Only available with the `std` feature. + fn decrypt_with_aad( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], ) -> Result, SymmetricCipherError> { let mut plaintext = vec![0u8; Self::decrypt_out_max_len(ciphertext.len())]; - let written = Self::decrypt_out(key, nonce, aad, ciphertext, tag, &mut plaintext)?; + let written = Self::decrypt_out_with_aad(key, nonce, aad, ciphertext, &mut plaintext)?; plaintext.truncate(written); Ok(plaintext) } } -/// The encryption half of an AEAD cipher's streaming API. This is the AEAD counterpart of -/// [`SymmetricCipherEncryptor`] -- the same separate-output, init-data-generating, possibly-buffering -/// shape -- with the two differences that authentication forces. +/// The encryption half of an AEAD cipher's streaming API. This extends +/// [`SymmetricCipherEncryptor`] -- the same separate-output, init-data-generating, +/// possibly-buffering shape -- with the two things authentication adds. +/// +/// # Two tag layouts /// -/// The first is an extra phase. An AEAD authenticates data it does not encrypt -- additional -/// authenticated data (AAD), typically a header that has to travel in the clear but must still be -/// protected against tampering -- and every AEAD construction absorbs that AAD *before* the -/// plaintext. So [`do_update_aad`](Self::do_update_aad) may be called any number of times after -/// the constructor and before the first [`do_update_out`](Self::do_update_out), and returns +/// The inherited [`SymmetricCipherEncryptor`] methods are this AEAD with no associated data and +/// the tag *inline*: [`SymmetricCipherEncryptor::do_final`] flushes whatever ciphertext was held +/// back and appends the tag after it, so the output is simply `ciphertext || tag`. `FINAL_LEN` is +/// therefore the tag length plus whatever the cipher holds back, and a caller that holds a +/// [`SymmetricCipherEncryptor`] can use an AEAD without knowing it is one. +/// +/// The methods ending in `_detached` hand the tag back as a value of its own instead, for callers +/// whose protocol carries it in a separate field. [`do_final_detached`](Self::do_final_detached) / +/// [`do_final_out_detached`](Self::do_final_out_detached) consume the encryptor, flush the +/// held-back ciphertext and return the tag, which the recipient needs for +/// [`AEADCipherDecryptor::do_final_detached`]. +/// +/// The methods ending in `_with_aad` are the inherited inline-tag one-shots with an `aad` +/// parameter added: [`encrypt_out_with_aad`](Self::encrypt_out_with_aad) is +/// [`SymmetricCipherEncryptor::encrypt_out`] with associated data, and so on. +/// +/// # Associated data +/// +/// An AEAD authenticates data it does not encrypt -- additional authenticated data (AAD), +/// typically a header that has to travel in the clear but must still be protected against +/// tampering -- and every AEAD construction absorbs that AAD *before* the plaintext. So +/// [`do_update_aad`](Self::do_update_aad) may be called any number of times after the constructor +/// and before the first [`SymmetricCipherEncryptor::do_update_out`], and returns /// [`SymmetricCipherError::StateError`] thereafter. (An empty `aad` slice is a no-op and is /// accepted at any point, so a generic caller may pass one unconditionally.) That is a runtime /// error for the same reason [`XOF`] rejects absorb-after-squeeze at runtime: the phase order is a /// property of a value's history, and encoding it in the type would cost every implementor an -/// extra type and an explicit transition. -/// -/// The second is a finalization step that also produces a tag: [`do_encrypt_final`](Self::do_encrypt_final) -/// consumes the encryptor, flushes whatever ciphertext it was holding back into `output`, and -/// returns the tag, which the recipient needs for [`AEADCipherDecryptor::do_decrypt_final`]. Where -/// the tag travels -- appended to the ciphertext, carried in a separate field -- is the caller's -/// choice, not this trait's, which is why the inline layout has its own entry points -/// ([`tagged_encrypt`](Self::tagged_encrypt), -/// [`tagged_do_aead_encrypt_final`](Self::tagged_do_aead_encrypt_final)) rather than being the -/// only thing on offer. +/// extra type and an explicit transition. Not calling it at all is the no-AAD case the inherited +/// methods cover. /// /// Encryption and decryption are separate traits, as with [`BlockCipherEncryptor`] / /// [`BlockCipherDecryptor`], so that the direction is encoded in the type. For an AEAD that also @@ -294,158 +286,94 @@ pub trait AEADCipherDecryptor< /// /// # A cipher may buffer /// -/// [`do_update_out`](Self::do_update_out) takes separate input and output buffers, because an AEAD -/// is not guaranteed to release a ciphertext byte the moment it sees the matching plaintext byte. -/// Ascon-AEAD128 does -- each rate-block byte is transformed independently of the others in that -/// block -- but a block-oriented AEAD holds back a partial final block, and any AEAD adapted to an -/// inline `ciphertext || tag` layout must hold back at least `TAG_LEN` bytes until it knows they -/// are not the tag (which is what a caller of -/// [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`] does for itself). -/// [`update_out_len`](Self::update_out_len) -/// answers exactly how many bytes the next call releases, so a caller never has to guess a buffer -/// size or find plaintext left over at the end of one it guessed too large; the concatenation of -/// everything released, in any chunking, plus the data part of -/// [`do_encrypt_final`](Self::do_encrypt_final), is the ciphertext. -/// -/// # Any length, as a slice -/// -/// [`do_update_out`](Self::do_update_out)'s input is a `&[u8]` rather than a `&[u8; LEN]` because -/// every length is valid, including zero, so there is no invariant for a const parameter to carry -/// and nothing for a compile-time check to check -- the same reasoning as -/// [`StreamCipherEncryptor`], and the reason there is no `BLOCK_LEN` here. +/// [`SymmetricCipherEncryptor::do_update_out`] takes separate input and output buffers, because an +/// AEAD is not guaranteed to release a ciphertext byte the moment it sees the matching plaintext +/// byte. Ascon-AEAD128 does -- each rate-block byte is transformed independently of the others in +/// that block -- but a block-oriented AEAD holds back a partial final block, and every decryptor +/// holds back at least `TAG_LEN` bytes until it knows they are not the tag (see +/// [`AEADCipherDecryptor`]). [`SymmetricCipherEncryptor::update_out_len`] answers exactly how many +/// bytes the next call releases, so a caller never has to guess a buffer size or find plaintext +/// left over at the end of one it guessed too large; the concatenation of everything released, in +/// any chunking, plus the data part of the final call, is the ciphertext. /// /// # Why the data methods still return `Result` /// /// Nothing about the buffer can go wrong, and a constructed value is always ready to use, so -/// [`do_update_out`](Self::do_update_out) has nothing to report for most ciphers. The `Result` is -/// for the per-(key, nonce) data limit an AEAD generally has -- past it the construction's security -/// argument no longer holds -- which a streaming API cannot check any earlier than the call that -/// would cross it, and for [`OutputBufferTooSmall`](SymmetricCipherError::OutputBufferTooSmall) -/// if the caller under-sized `ciphertext`. +/// `do_update_out` has nothing to report for most ciphers. The `Result` is for the per-(key, nonce) +/// data limit an AEAD generally has -- past it the construction's security argument no longer +/// holds -- which a streaming API cannot check any earlier than the call that would cross it, and +/// for [`OutputBufferTooSmall`](SymmetricCipherError::OutputBufferTooSmall) if the caller +/// under-sized `ciphertext`. pub trait AEADCipherEncryptor< const KEY_LEN: usize, const NONCE_LEN: usize, const TAG_LEN: usize, const FINAL_LEN: usize, ->: Algorithm + Sized +>: SymmetricCipherEncryptor { - /// Begins a streaming encryption flow, returning the encryptor and the generated nonce, which - /// the recipient needs for [`AEADCipherDecryptor::do_decrypt_init`]. Sources randomness from - /// the library's default OS-backed RNG. - /// - /// # Errors - /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose - /// security strength is below [`Algorithm::MAX_SECURITY_STRENGTH`], both as a - /// [`SymmetricCipherError::KeyMaterialError`]; a failure to draw the nonce comes back as a - /// [`SymmetricCipherError::RNGError`]. - fn do_encrypt_init( - key: &KeyMaterial, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError>; - - /// As [`do_encrypt_init`](Self::do_encrypt_init), but sources randomness from the provided RNG. - fn do_encrypt_init_rng( - key: &KeyMaterial, - rng: &mut dyn RNG, - ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError>; - /// Absorbs `aad`: data that is authenticated by the tag but not encrypted. May be called - /// repeatedly before the first [`do_update_out`](Self::do_update_out); a sequence of calls is - /// equivalent to one call over the concatenation. An empty `aad` is a no-op. + /// repeatedly before the first [`SymmetricCipherEncryptor::do_update_out`]; a sequence of calls + /// is equivalent to one call over the concatenation. An empty `aad` is a no-op. /// /// # Errors /// [`SymmetricCipherError::StateError`] if called with a non-empty `aad` after - /// [`do_update_out`](Self::do_update_out) -- see the trait docs for why the AAD comes first. + /// [`SymmetricCipherEncryptor::do_update_out`] -- see the trait docs for why the AAD comes + /// first. fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError>; - /// The exact number of bytes the next [`do_update_out`](Self::do_update_out) will write if - /// given `input_len` more bytes of plaintext. Depends on what is already buffered; identically - /// `input_len` for a cipher that never holds anything back, such as Ascon-AEAD128. - fn update_out_len(&self, input_len: usize) -> usize; - - /// Streaming: consumes `plaintext`, writing every ciphertext byte that can be produced so far - /// into `ciphertext` and buffering the rest. Returns the number of bytes written, which is - /// exactly [`update_out_len`](Self::update_out_len) of `plaintext.len()`. A sequence of calls - /// is equivalent to one call over the concatenation, whatever the chunking. + /// Finishes the encryption with the tag detached, consuming the encryptor: flushes whatever + /// plaintext was held back, encrypted, into `ciphertext`, and returns how many leading bytes of + /// it are ciphertext together with the tag over the AAD and plaintext it has seen. The tag must + /// be transmitted with the ciphertext; the recipient passes it to + /// [`AEADCipherDecryptor::do_final_out_detached`]. /// - /// # Errors - /// [`SymmetricCipherError::OutputBufferTooSmall`] if `ciphertext` is shorter than - /// [`update_out_len`](Self::update_out_len), carrying the required length. Nothing is - /// consumed in that case. - fn do_update_out( - &mut self, - plaintext: &[u8], - ciphertext: &mut [u8], - ) -> Result; - - /// Finishes the encryption, consuming the encryptor: flushes whatever plaintext was held back, - /// encrypted, into `output`, and returns how many leading bytes of it are ciphertext together - /// with the tag over the AAD and plaintext it has seen. The tag must be transmitted with the - /// ciphertext; the recipient passes it to [`AEADCipherDecryptor::do_decrypt_final`]. - fn do_encrypt_final( + /// `ciphertext` is `FINAL_LEN` long so that both final methods share one buffer size; the + /// flush written here is at most `FINAL_LEN - TAG_LEN` of it, the tag not being part of it. + fn do_final_out_detached( self, - output: &mut [u8; FINAL_LEN], + ciphertext: &mut [u8; FINAL_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError>; - /// Streaming finalization for the inline `ciphertext || tag` layout: as - /// [`do_encrypt_final`](Self::do_encrypt_final), except that the tag is *appended* to whatever - /// ciphertext was held back rather than returned on its own, so what this writes into `output` - /// is simply the tail of the stream [`do_update_out`](Self::do_update_out) has been writing. - /// Returns the number of bytes written: the flushed ciphertext plus `TAG_LEN`. - /// - /// `output` needs `FINAL_LEN + TAG_LEN` bytes -- the full flush, even where less than that is - /// actually being held back, since how much that is cannot be known until the cipher is - /// finalized. A streaming caller sizing its output with - /// [`tagged_encrypt_out_len`](Self::tagged_encrypt_out_len) therefore has to allocate - /// `FINAL_LEN` more than that if it wants to write the whole stream into one buffer. - /// - /// A caller who wants the tag as a field of its own calls - /// [`do_encrypt_final`](Self::do_encrypt_final) instead; the decrypting counterpart of this - /// method is [`AEADCipherDecryptor::tagged_do_aead_decrypt_final`]. - /// - /// # Errors - /// [`SymmetricCipherError::OutputBufferTooSmall`] if `output` is shorter than - /// `FINAL_LEN + TAG_LEN`, checked before the cipher is finalized. - fn tagged_do_aead_encrypt_final( + /// As [`do_final_out_detached`](Self::do_final_out_detached), returning the final buffer, the + /// number of leading bytes of it that are ciphertext, and the tag -- the shape of + /// [`SymmetricCipherEncryptor::do_final`] with the tag alongside. Provided over the `_out` + /// form, the other way round from the base trait's pair; see + /// [`AEADCipherDecryptor::do_final_detached`]. + fn do_final_detached( self, - output: &mut [u8], - ) -> Result { - let needed = FINAL_LEN + TAG_LEN; - if output.len() < needed { - return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); - } - let mut final_buf = [0u8; FINAL_LEN]; - let (final_len, tag) = self.do_encrypt_final(&mut final_buf)?; - output[..final_len].copy_from_slice(&final_buf[..final_len]); - output[final_len..final_len + TAG_LEN].copy_from_slice(&tag); - Ok(final_len + TAG_LEN) + ) -> Result<([u8; FINAL_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { + let mut ciphertext = [0u8; FINAL_LEN]; + let (out_len, tag) = self.do_final_out_detached(&mut ciphertext)?; + Ok((ciphertext, out_len, tag)) } - /// The exact ciphertext length for a `plaintext_len`-byte plaintext, i.e. the buffer - /// [`encrypt_out`](Self::encrypt_out) requires and the number of bytes it writes (the tag is - /// returned separately, not counted here). The default returns `plaintext_len` itself, which - /// holds for every conformant AEAD: unlike a padding scheme, an AEAD never expands or shrinks - /// the data it is given. - fn encrypt_out_len(plaintext_len: usize) -> usize { + /// The exact ciphertext length for a `plaintext_len`-byte plaintext with the tag detached, i.e. + /// the buffer [`encrypt_out_detached`](Self::encrypt_out_detached) requires and the number of + /// bytes it writes (the tag is returned separately, not counted here). The default returns + /// `plaintext_len` itself, which holds for every conformant AEAD: unlike a padding scheme, an + /// AEAD never expands or shrinks the data it is given. + fn encrypt_out_len_detached(plaintext_len: usize) -> usize { plaintext_len } - /// One-shot: encrypts `plaintext` into `ciphertext`, which needs - /// [`encrypt_out_len`](Self::encrypt_out_len) bytes, authenticating `aad` along with it under a - /// fresh nonce. Returns the generated nonce, the number of bytes written, and the tag. + /// One-shot with the tag detached: encrypts `plaintext` into `ciphertext`, which needs + /// [`encrypt_out_len_detached`](Self::encrypt_out_len_detached) bytes, authenticating `aad` + /// along with it under a fresh nonce. Returns the generated nonce, the number of bytes + /// written, and the tag. /// /// Provided as `do_encrypt_init`, one `do_update_aad`, one `do_update_out` and - /// `do_encrypt_final`. + /// `do_final_out_detached`. /// /// # Errors /// [`SymmetricCipherError::OutputBufferTooSmall`] if `ciphertext` is too short, checked /// before any work is done; otherwise whatever the streaming methods return. - fn encrypt_out( + fn encrypt_out_detached( key: &KeyMaterial, aad: &[u8], plaintext: &[u8], ciphertext: &mut [u8], ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { - let needed = Self::encrypt_out_len(plaintext.len()); + let needed = Self::encrypt_out_len_detached(plaintext.len()); if ciphertext.len() < needed { return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); } @@ -453,22 +381,24 @@ pub trait AEADCipherEncryptor< enc.do_update_aad(aad)?; let written = enc.do_update_out(plaintext, ciphertext)?; let mut final_buf = [0u8; FINAL_LEN]; - let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; - // Implementors with FINAL_LEN > 0 must override `encrypt_out_len` so this fits in + let (final_len, tag) = enc.do_final_out_detached(&mut final_buf)?; + // Implementors that hold plaintext back must override `encrypt_out_len_detached` if + // `written + final_len` can exceed the plaintext length, so this fits in // `ciphertext[..needed]`. ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok((nonce, written + final_len, tag)) } - /// As [`encrypt_out`](Self::encrypt_out), but sources randomness from the provided RNG. - fn encrypt_out_rng( + /// As [`encrypt_out_detached`](Self::encrypt_out_detached), but sources randomness from the + /// provided RNG. + fn encrypt_out_rng_detached( key: &KeyMaterial, rng: &mut dyn RNG, aad: &[u8], plaintext: &[u8], ciphertext: &mut [u8], ) -> Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError> { - let needed = Self::encrypt_out_len(plaintext.len()); + let needed = Self::encrypt_out_len_detached(plaintext.len()); if ciphertext.len() < needed { return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); } @@ -476,56 +406,92 @@ pub trait AEADCipherEncryptor< enc.do_update_aad(aad)?; let written = enc.do_update_out(plaintext, ciphertext)?; let mut final_buf = [0u8; FINAL_LEN]; - let (final_len, tag) = enc.do_encrypt_final(&mut final_buf)?; - // As in `encrypt_out`: an implementor with FINAL_LEN > 0 must override `encrypt_out_len` - // so this fits in `ciphertext[..needed]`. + let (final_len, tag) = enc.do_final_out_detached(&mut final_buf)?; + // As in `encrypt_out_detached`. ciphertext[written..written + final_len].copy_from_slice(&final_buf[..final_len]); Ok((nonce, written + final_len, tag)) } - /// The ciphertext buffer [`tagged_encrypt`](Self::tagged_encrypt) requires: what the - /// separate-tag [`encrypt_out`](Self::encrypt_out) needs, plus the `TAG_LEN` bytes appended to - /// it. - fn tagged_encrypt_out_len(plaintext_len: usize) -> usize { - Self::encrypt_out_len(plaintext_len) + TAG_LEN - } - - /// One-shot into the inline `ciphertext || tag` layout: as [`encrypt_out`](Self::encrypt_out), - /// except that the tag is appended to `ciphertext` instead of being returned separately. - /// `ciphertext` needs [`tagged_encrypt_out_len`](Self::tagged_encrypt_out_len) bytes. Returns - /// the generated nonce and the total number of bytes written, tag included. + /// One-shot into the inline `ciphertext || tag` layout with associated data: this is + /// [`SymmetricCipherEncryptor::encrypt_out`] with an `aad`, and needs the same + /// [`encrypt_out_len`](SymmetricCipherEncryptor::encrypt_out_len) bytes of `ciphertext`. + /// Returns the generated nonce and the total number of bytes written, tag included. /// /// # Errors /// [`SymmetricCipherError::OutputBufferTooSmall`] if `ciphertext` is too short, checked - /// before any work is done; otherwise as [`encrypt_out`](Self::encrypt_out). - fn tagged_encrypt( + /// before any work is done; otherwise whatever the streaming methods return. + fn encrypt_out_with_aad( key: &KeyMaterial, aad: &[u8], plaintext: &[u8], ciphertext: &mut [u8], ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError> { - let needed = Self::tagged_encrypt_out_len(plaintext.len()); + let needed = Self::encrypt_out_len(plaintext.len()); if ciphertext.len() < needed { return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); } - let (nonce, written, tag) = Self::encrypt_out(key, aad, plaintext, ciphertext)?; - ciphertext[written..written + TAG_LEN].copy_from_slice(&tag); - Ok((nonce, written + TAG_LEN)) + let (mut enc, nonce) = Self::do_encrypt_init(key)?; + enc.do_update_aad(aad)?; + let written = enc.do_update_out(plaintext, ciphertext)?; + let (last, last_len) = enc.do_final()?; + // `encrypt_out_len` is exactly `written + last_len`, so this fits in `ciphertext[..needed]`. + ciphertext[written..written + last_len].copy_from_slice(&last[..last_len]); + Ok((nonce, written + last_len)) + } + + /// As [`encrypt_out_with_aad`](Self::encrypt_out_with_aad), but sources randomness from the + /// provided RNG: [`SymmetricCipherEncryptor::encrypt_out_rng`] with an `aad`. + fn encrypt_out_rng_with_aad( + key: &KeyMaterial, + rng: &mut dyn RNG, + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<([u8; NONCE_LEN], usize), SymmetricCipherError> { + let needed = Self::encrypt_out_len(plaintext.len()); + if ciphertext.len() < needed { + return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); + } + let (mut enc, nonce) = Self::do_encrypt_init_rng(key, rng)?; + enc.do_update_aad(aad)?; + let written = enc.do_update_out(plaintext, ciphertext)?; + let (last, last_len) = enc.do_final()?; + // As in `encrypt_out_with_aad`. + ciphertext[written..written + last_len].copy_from_slice(&last[..last_len]); + Ok((nonce, written + last_len)) } #[cfg(feature = "std")] - /// One-shot, allocating: as [`encrypt_out`](Self::encrypt_out), returning the ciphertext as a + /// One-shot, allocating, with the tag detached: as + /// [`encrypt_out_detached`](Self::encrypt_out_detached), returning the ciphertext as a /// `Vec`. Only available with the `std` feature. - fn encrypt( + fn encrypt_detached( key: &KeyMaterial, aad: &[u8], plaintext: &[u8], ) -> Result, SymmetricCipherError> { - let mut ciphertext = vec![0u8; Self::encrypt_out_len(plaintext.len())]; - let (nonce, written, tag) = Self::encrypt_out(key, aad, plaintext, &mut ciphertext)?; + let mut ciphertext = vec![0u8; Self::encrypt_out_len_detached(plaintext.len())]; + let (nonce, written, tag) = + Self::encrypt_out_detached(key, aad, plaintext, &mut ciphertext)?; ciphertext.truncate(written); Ok((nonce, ciphertext, tag)) } + + #[cfg(feature = "std")] + /// One-shot, allocating, into the inline `ciphertext || tag` layout with associated data: as + /// [`encrypt_out_with_aad`](Self::encrypt_out_with_aad), returning the ciphertext, tag + /// included, as a `Vec`. This is [`SymmetricCipherEncryptor::encrypt`] with an `aad`. Only + /// available with the `std` feature. + fn encrypt_with_aad( + key: &KeyMaterial, + aad: &[u8], + plaintext: &[u8], + ) -> Result<([u8; NONCE_LEN], Vec), SymmetricCipherError> { + let mut ciphertext = vec![0u8; Self::encrypt_out_len(plaintext.len())]; + let (nonce, written) = Self::encrypt_out_with_aad(key, aad, plaintext, &mut ciphertext)?; + ciphertext.truncate(written); + Ok((nonce, ciphertext)) + } } /// Metadata about a cryptographic algorithm. @@ -1875,7 +1841,9 @@ pub trait SymmetricCipherDecryptor< /// [`decrypt_out_max_len`](Self::decrypt_out_max_len) bytes. Returns the number of plaintext /// bytes written. /// - /// Provided as `do_decrypt_init`, one `do_update_out` and `do_final`. + /// Provided as `do_decrypt_init`, one `do_update_out` and `do_final`. If `do_final` fails -- + /// a bad tag, bad padding -- the plaintext already written is zeroized before the error is + /// returned, so a caller who ignores the `Result` is not left holding unauthenticated data. /// /// # Errors /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is too short, checked @@ -1892,10 +1860,21 @@ pub trait SymmetricCipherDecryptor< } let mut dec = Self::do_decrypt_init(key, init_data)?; let written = dec.do_update_out(ciphertext, plaintext)?; - let (last, data_len) = dec.do_final()?; - // `decrypt_out_max_len` bounds `written + data_len`, so this fits in `plaintext[..needed]`. - plaintext[written..written + data_len].copy_from_slice(&last[..data_len]); - Ok(written + data_len) + match dec.do_final() { + Ok((last, data_len)) => { + // `decrypt_out_max_len` bounds `written + data_len`, so this fits in + // `plaintext[..needed]`. + plaintext[written..written + data_len].copy_from_slice(&last[..data_len]); + Ok(written + data_len) + } + Err(e) => { + // An AEAD reaches this one-shot through its `SymmetricCipherDecryptor` side, and + // what `do_update_out` released is unauthenticated; see + // `AEADCipherDecryptor::decrypt_out_detached` for why a plain `fill` is enough. + plaintext[..written].fill(0); + Err(e) + } + } } #[cfg(feature = "std")] diff --git a/crypto/core/tests/aead_tagged_tests.rs b/crypto/core/tests/aead_tagged_tests.rs index 3660217b..6d951bb3 100644 --- a/crypto/core/tests/aead_tagged_tests.rs +++ b/crypto/core/tests/aead_tagged_tests.rs @@ -1,7 +1,8 @@ //! Integration tests for the inline `ciphertext || tag` layout on -//! [`AEADCipherEncryptor`]/[`AEADCipherDecryptor`] -- `tagged_encrypt`, -//! `tagged_do_aead_encrypt_final`, `tagged_decrypt` and `tagged_do_aead_decrypt_final` -- driven -//! over a toy AEAD, which is what lets the length and tag-placement edges be checked exactly. +//! [`AEADCipherEncryptor`]/[`AEADCipherDecryptor`] -- the `encrypt_out_with_aad` / `decrypt_out_with_aad` +//! one-shots, and the inherited `SymmetricCipherEncryptor` / `SymmetricCipherDecryptor` streaming +//! and one-shot methods they sit beside -- driven over a toy AEAD, which is what lets the length +//! and tag-placement edges be checked exactly. use bouncycastle_core::errors::SymmetricCipherError; use bouncycastle_core::key_material::{ @@ -9,6 +10,7 @@ use bouncycastle_core::key_material::{ }; use bouncycastle_core::traits::{ AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, + SymmetricCipherDecryptor, SymmetricCipherEncryptor, }; use bouncycastle_utils::secret::Secret; @@ -18,7 +20,7 @@ const TAG_LEN: usize = 3; /// A toy AEAD: "ciphertext" is the plaintext XORed byte-by-byte with the key (cycled), and the /// "tag" is a running XOR of every AAD/plaintext byte seen, repeated to `TAG_LEN` bytes. Not -/// remotely secure -- it exists only to drive the `tagged_*` defaults at exact byte-boundary edge +/// remotely secure -- it exists only to drive the inline-layout defaults at exact byte-boundary edge /// cases around `TAG_LEN`, with a `TAG_LEN` small enough (3) that "the tag is the last few bytes" /// and "the message is shorter than the tag" are both cheap to enumerate. #[derive(Clone)] @@ -54,7 +56,6 @@ impl Toy { } struct ToyEnc(Toy); -struct ToyDec(Toy); impl Algorithm for ToyEnc { const ALG_NAME: &'static str = "toy-aead"; @@ -65,7 +66,7 @@ impl Algorithm for ToyDec { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::None; } -impl AEADCipherEncryptor for ToyEnc { +impl SymmetricCipherEncryptor for ToyEnc { fn do_encrypt_init( key: &KeyMaterial, ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { @@ -77,12 +78,6 @@ impl AEADCipherEncryptor for ToyEnc { ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { Self::do_encrypt_init(key) } - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - for &b in aad { - self.0.acc ^= b; - } - Ok(()) - } fn update_out_len(&self, input_len: usize) -> usize { input_len } @@ -99,52 +94,99 @@ impl AEADCipherEncryptor for ToyEnc { self.0.transform(out, true); Ok(plaintext.len()) } - fn do_encrypt_final( + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + Ok(([self.0.acc; TAG_LEN], TAG_LEN)) + } + fn encrypt_out_len(plaintext_len: usize) -> usize { + plaintext_len + TAG_LEN + } +} + +impl AEADCipherEncryptor for ToyEnc { + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.0.acc ^= b; + } + Ok(()) + } + fn do_final_out_detached( self, - _output: &mut [u8; 0], + _ciphertext: &mut [u8; TAG_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { Ok((0, [self.0.acc; TAG_LEN])) } } -impl AEADCipherDecryptor for ToyDec { +/// Holds back the last `TAG_LEN` bytes of ciphertext seen, as every AEAD decryptor must. +struct ToyDec { + toy: Toy, + held: [u8; TAG_LEN], + held_len: usize, +} + +impl SymmetricCipherDecryptor for ToyDec { fn do_decrypt_init( key: &KeyMaterial, _nonce: &[u8; NONCE_LEN], ) -> Result { - Ok(Self(Toy::new(key)?)) - } - fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - for &b in aad { - self.0.acc ^= b; - } - Ok(()) + Ok(Self { toy: Toy::new(key)?, held: [0u8; TAG_LEN], held_len: 0 }) } fn update_out_len(&self, input_len: usize) -> usize { - input_len + (self.held_len + input_len).saturating_sub(TAG_LEN) } fn do_update_out( &mut self, ciphertext: &[u8], plaintext: &mut [u8], ) -> Result { - if plaintext.len() < ciphertext.len() { - return Err(SymmetricCipherError::OutputBufferTooSmall(ciphertext.len())); + let release = self.update_out_len(ciphertext.len()); + if plaintext.len() < release { + return Err(SymmetricCipherError::OutputBufferTooSmall(release)); } - let out = &mut plaintext[..ciphertext.len()]; - out.copy_from_slice(ciphertext); - self.0.transform(out, false); - Ok(ciphertext.len()) + // The same byte-queue shuffle as `AsconAead128Decryptor::do_update_out`, over a stream of + // `held || ciphertext`. + let mut stream = self.held[..self.held_len].to_vec(); + stream.extend_from_slice(ciphertext); + let out = &mut plaintext[..release]; + out.copy_from_slice(&stream[..release]); + self.toy.transform(out, false); + self.held_len = stream.len() - release; + self.held[..self.held_len].copy_from_slice(&stream[release..]); + Ok(release) } - fn do_decrypt_final( - self, + fn do_final(self) -> Result<([u8; TAG_LEN], usize), SymmetricCipherError> { + if self.held_len < TAG_LEN { + return Err(SymmetricCipherError::DecryptionFailed); + } + if [self.toy.acc; TAG_LEN] != self.held { + return Err(SymmetricCipherError::AEADTagCheckFailed); + } + Ok(([0u8; TAG_LEN], 0)) + } + fn decrypt_out_max_len(ciphertext_len: usize) -> usize { + ciphertext_len.saturating_sub(TAG_LEN) + } +} + +impl AEADCipherDecryptor for ToyDec { + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + for &b in aad { + self.toy.acc ^= b; + } + Ok(()) + } + fn do_final_out_detached( + mut self, tag: &[u8; TAG_LEN], - _output: &mut [u8; 0], + plaintext: &mut [u8; TAG_LEN], ) -> Result { - if [self.0.acc; TAG_LEN] != *tag { + let n = self.held_len; + plaintext[..n].copy_from_slice(&self.held[..n]); + self.toy.transform(&mut plaintext[..n], false); + if [self.toy.acc; TAG_LEN] != *tag { return Err(SymmetricCipherError::AEADTagCheckFailed); } - Ok(0) + Ok(n) } } @@ -164,16 +206,16 @@ const AAD: &[u8] = b"aad"; /// Encrypts `msg` into the inline layout with the one-shot, and returns it. fn tagged_ct(km: &KeyMaterial, msg: &[u8]) -> (Vec, [u8; NONCE_LEN]) { - let mut ct = vec![0u8; ToyEnc::tagged_encrypt_out_len(msg.len())]; - let (nonce, written) = ToyEnc::tagged_encrypt(km, AAD, msg, &mut ct).unwrap(); + let mut ct = vec![0u8; ToyEnc::encrypt_out_len(msg.len())]; + let (nonce, written) = ToyEnc::encrypt_out_with_aad(km, AAD, msg, &mut ct).unwrap(); assert_eq!(written, msg.len() + TAG_LEN, "inline layout is ciphertext || tag"); ct.truncate(written); (ct, nonce) } /// The one-shot pair round-trips at every length crossing a few multiples of `TAG_LEN`, and the -/// streaming pair agrees with it for every chunking -- the caller holding back the last `TAG_LEN` -/// bytes itself, as `tagged_do_aead_decrypt_final`'s docs require. +/// streaming pair agrees with it for every chunking -- the decryptor, not the caller, holding back +/// the last `TAG_LEN` bytes as the possible tag. #[test] fn tagged_round_trip_at_every_length_and_chunking() { let km = key(); @@ -181,10 +223,17 @@ fn tagged_round_trip_at_every_length_and_chunking() { let msg: Vec = (0..len).map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)).collect(); let (ct, nonce) = tagged_ct(&km, &msg); - let mut pt = vec![0u8; ToyDec::tagged_decrypt_out_max_len(ct.len())]; - let n = ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct, &mut pt).unwrap(); + let mut pt = vec![0u8; ToyDec::decrypt_out_max_len(ct.len())]; + let n = ToyDec::decrypt_out_with_aad(&km, &nonce, AAD, &ct, &mut pt).unwrap(); assert_eq!(&pt[..n], &msg[..], "len {len}: one-shot round trip"); + // The detached layout is the same ciphertext with the tag split off. + let mut detached = vec![0u8; ToyEnc::encrypt_out_len_detached(len)]; + let (_, d_len, d_tag) = + ToyEnc::encrypt_out_detached(&km, AAD, &msg, &mut detached).unwrap(); + assert_eq!(&detached[..d_len], &ct[..len], "len {len}: detached ciphertext"); + assert_eq!(&d_tag[..], &ct[len..], "len {len}: detached tag"); + for chunk in [1usize, 2, 3, TAG_LEN.max(1), len.max(1)] { // Encrypt in chunks, finishing with the tag appended by the streaming finalizer. let (mut enc, stream_nonce) = ToyEnc::do_encrypt_init(&km).unwrap(); @@ -194,33 +243,51 @@ fn tagged_round_trip_at_every_length_and_chunking() { for piece in msg.chunks(chunk) { written += enc.do_update_out(piece, &mut stream_ct[written..]).unwrap(); } - written += enc.tagged_do_aead_encrypt_final(&mut stream_ct[written..]).unwrap(); + let mut last = [0u8; TAG_LEN]; + let last_len = enc.do_final_out(&mut last).unwrap(); + stream_ct[written..written + last_len].copy_from_slice(&last[..last_len]); + written += last_len; stream_ct.truncate(written); assert_eq!( stream_ct, ct, "len {len}, chunk {chunk}: streaming must match the one-shot" ); - // Decrypt in chunks, holding back the last TAG_LEN bytes for the finalizer. + // Decrypt in chunks, tag and all: the decryptor holds the tag back itself. let mut dec = ToyDec::do_decrypt_init(&km, &stream_nonce).unwrap(); dec.do_update_aad(AAD).unwrap(); - let body_len = stream_ct.len() - TAG_LEN; let mut out = vec![0u8; stream_ct.len()]; let mut written = 0; - for piece in stream_ct[..body_len].chunks(chunk) { + for piece in stream_ct.chunks(chunk) { written += dec.do_update_out(piece, &mut out[written..]).unwrap(); } - written += dec - .tagged_do_aead_decrypt_final(&stream_ct[body_len..], &mut out[written..]) - .unwrap(); - out.truncate(written); + assert_eq!(written, len, "len {len}, chunk {chunk}: the tag must be held back"); + let (last, data_len) = dec.do_final().unwrap(); + assert_eq!(data_len, 0, "len {len}: nothing but the tag was held back"); + out[written..written + data_len].copy_from_slice(&last[..data_len]); + out.truncate(written + data_len); assert_eq!(out, msg, "len {len}, chunk {chunk}: streaming round trip"); + + // The same held-back bytes are ciphertext if the tag is detached. + let mut dec = ToyDec::do_decrypt_init(&km, &stream_nonce).unwrap(); + dec.do_update_aad(AAD).unwrap(); + let mut out = vec![0u8; len]; + let mut written = 0; + for piece in stream_ct[..len].chunks(chunk) { + written += dec.do_update_out(piece, &mut out[written..]).unwrap(); + } + let mut last = [0u8; TAG_LEN]; + let last_len = dec.do_final_out_detached(&d_tag, &mut last).unwrap(); + assert_eq!(written + last_len, len, "len {len}: detached final flushes the rest"); + out[written..].copy_from_slice(&last[..last_len]); + assert_eq!(out, msg, "len {len}, chunk {chunk}: detached streaming round trip"); } } } -/// A tampered inline stream fails at finalization on both entry points, and an input shorter than -/// the tag is rejected as `DecryptionFailed` rather than panicking on the short slice. +/// A tampered inline stream fails at finalization on both entry points, zeroizing the one-shot's +/// buffer, and an input shorter than the tag is rejected as `DecryptionFailed` rather than +/// panicking on the short slice. #[test] fn tampering_and_short_input_are_rejected() { let km = key(); @@ -231,77 +298,68 @@ fn tampering_and_short_input_are_rejected() { tampered[0] ^= 0xFF; let mut pt = vec![0u8; tampered.len()]; assert!(matches!( - ToyDec::tagged_decrypt(&km, &nonce, AAD, &tampered, &mut pt), + ToyDec::decrypt_out_with_aad(&km, &nonce, AAD, &tampered, &mut pt), Err(SymmetricCipherError::AEADTagCheckFailed) )); assert_eq!(pt, vec![0u8; tampered.len()], "the one-shot zeroizes on a failed tag check"); let mut dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); dec.do_update_aad(AAD).unwrap(); + dec.do_update_out(&tampered, &mut pt).unwrap(); + assert!(matches!(dec.do_final(), Err(SymmetricCipherError::AEADTagCheckFailed))); + + // A wrong detached tag fails, and `decrypt_out_detached` zeroizes what it wrote. + let mut wrong_tag = [0u8; TAG_LEN]; + wrong_tag.copy_from_slice(&ct[msg.len()..]); + wrong_tag[0] ^= 0xFF; + let mut pt = vec![0u8; msg.len()]; assert!(matches!( - dec.tagged_do_aead_decrypt_final(&tampered, &mut pt), + ToyDec::decrypt_out_detached(&km, &nonce, AAD, &ct[..msg.len()], &wrong_tag, &mut pt), Err(SymmetricCipherError::AEADTagCheckFailed) )); + assert_eq!(pt, vec![0u8; msg.len()], "the detached one-shot zeroizes on a failed tag check"); for short_len in 0..TAG_LEN { let mut pt = vec![0u8; TAG_LEN]; assert!(matches!( - ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct[..short_len], &mut pt), - Err(SymmetricCipherError::DecryptionFailed) - )); - let dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); - assert!(matches!( - dec.tagged_do_aead_decrypt_final(&ct[..short_len], &mut pt), + ToyDec::decrypt_out_with_aad(&km, &nonce, AAD, &ct[..short_len], &mut pt), Err(SymmetricCipherError::DecryptionFailed) )); + let mut dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); + assert_eq!(dec.do_update_out(&ct[..short_len], &mut pt).unwrap(), 0); + assert!(matches!(dec.do_final(), Err(SymmetricCipherError::DecryptionFailed))); } } -/// Every `tagged_*` entry point refuses an output buffer that is one byte short, naming the length -/// it needs, and does so before touching the cipher. +/// Every inline one-shot refuses an output buffer that is one byte short, naming the length it +/// needs, and does so before touching the cipher; one of exactly that length is accepted. #[test] fn tagged_undersized_buffers_are_rejected() { let km = key(); let msg = [3u8; 8]; let (ct, nonce) = tagged_ct(&km, &msg); - let needed = ToyEnc::tagged_encrypt_out_len(msg.len()); + let needed = ToyEnc::encrypt_out_len(msg.len()); assert_eq!(needed, msg.len() + TAG_LEN); let mut short = vec![0u8; needed - 1]; - match ToyEnc::tagged_encrypt(&km, AAD, &msg, &mut short) { + match ToyEnc::encrypt_out_with_aad(&km, AAD, &msg, &mut short) { Err(SymmetricCipherError::OutputBufferTooSmall(n)) => assert_eq!(n, needed), - other => panic!("tagged_encrypt into a short buffer: {other:?}"), + other => panic!("encrypt_out_with_aad into a short buffer: {other:?}"), } - let (enc, _) = ToyEnc::do_encrypt_init(&km).unwrap(); - let mut short = [0u8; TAG_LEN - 1]; - match enc.tagged_do_aead_encrypt_final(&mut short) { - Err(SymmetricCipherError::OutputBufferTooSmall(n)) => assert_eq!(n, TAG_LEN), - other => panic!("tagged_do_aead_encrypt_final into a short buffer: {other:?}"), - } - - let needed = ToyDec::tagged_decrypt_out_max_len(ct.len()); + let needed = ToyDec::decrypt_out_max_len(ct.len()); assert_eq!(needed, msg.len()); let mut short = vec![0u8; needed - 1]; - match ToyDec::tagged_decrypt(&km, &nonce, AAD, &ct, &mut short) { + match ToyDec::decrypt_out_with_aad(&km, &nonce, AAD, &ct, &mut short) { Err(SymmetricCipherError::OutputBufferTooSmall(n)) => assert_eq!(n, needed), - other => panic!("tagged_decrypt into a short buffer: {other:?}"), - } - - let dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); - let mut short = vec![0u8; msg.len() - 1]; - match dec.tagged_do_aead_decrypt_final(&ct, &mut short) { - Err(SymmetricCipherError::OutputBufferTooSmall(n)) => assert_eq!(n, msg.len()), - other => panic!("tagged_do_aead_decrypt_final into a short buffer: {other:?}"), + other => panic!("decrypt_out_with_aad into a short buffer: {other:?}"), } // A buffer of exactly the length it asks for must be accepted. Without this the // `plaintext.len() < needed` guard can be weakened to `<=` or `==` without any test noticing: // a too-short buffer is caught either way, by the guard or by `do_update_out` behind it, and // both report the same error with the same length. - let mut dec = ToyDec::do_decrypt_init(&km, &nonce).unwrap(); - dec.do_update_aad(AAD).unwrap(); - let mut exact = vec![0u8; msg.len()]; - let n = dec.tagged_do_aead_decrypt_final(&ct, &mut exact).unwrap(); + let mut exact = vec![0u8; needed]; + let n = ToyDec::decrypt_out_with_aad(&km, &nonce, AAD, &ct, &mut exact).unwrap(); assert_eq!(&exact[..n], &msg[..], "a buffer of exactly `needed` bytes must be enough"); } From 2eb7a99e121c9ad6f6b5467435ee8d4bb52efe65 Mon Sep 17 00:00:00 2001 From: David Hook Date: Thu, 24 Sep 2026 10:44:02 +1000 Subject: [PATCH 16/16] ascon: add Ascon_AEAD128, naming the AEAD pair by direction Ascon_AEAD128 is AsconAead128Encryptor and Ascon_AEAD128 is AsconAead128Decryptor, spelled as SP 800-232 spells the algorithm. A plain type alias cannot choose between two distinct types, so it is written as a projection through AsconAead128Mode, which is implemented for the two bouncycastle-modes direction markers and nothing else; any other Dir is a compile error. bouncycastle-ascon now depends on bouncycastle-modes for those markers only. The alias is re-exported at the crate root, carries a doctest of the no-AAD inline-tag round trip, and aead128_tests.rs runs the AEAD conformance suite through it. Assisted-by: Claude:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- alpha_0.1.3_release_notes.md | 2 ++ crypto/ascon/Cargo.toml | 2 ++ crypto/ascon/src/ascon_aead128.rs | 53 +++++++++++++++++++++++++++++ crypto/ascon/src/lib.rs | 1 + crypto/ascon/tests/aead128_tests.rs | 17 +++++++++ 5 files changed, 75 insertions(+) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 617f493a..4ce74352 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -9,6 +9,8 @@ `AsconAead128Encryptor` / `AsconAead128Decryptor` implement the generated-nonce `AEADCipherEncryptor` / `AEADCipherDecryptor` pair; the inherent `AsconAead128` API keeps the explicit-nonce, in-place streaming form (`new_encrypting` / `new_decrypting`). + `Ascon_AEAD128` names the pair by direction (`Ascon_AEAD128` / + `Ascon_AEAD128`). * `bouncycastle-ascon` is re-exported as `bouncycastle::ascon`; `Ascon-Hash256` and `Ascon-XOF128` are registered in the factories, and the CLI adds `ascon-hash256`, `ascon-xof128`, `ascon-cxof128` and `ascon-aead128`. The AEAD command generates and prefixes diff --git a/crypto/ascon/Cargo.toml b/crypto/ascon/Cargo.toml index 25a58829..1ee94e04 100644 --- a/crypto/ascon/Cargo.toml +++ b/crypto/ascon/Cargo.toml @@ -12,6 +12,8 @@ std = ["bouncycastle-core/std"] [dependencies] bouncycastle-core.workspace = true +# Only for the `Encrypting` / `Decrypting` markers `Ascon_AEAD128` takes; nothing else from it. +bouncycastle-modes.workspace = true bouncycastle-rng.workspace = true bouncycastle-utils.workspace = true diff --git a/crypto/ascon/src/ascon_aead128.rs b/crypto/ascon/src/ascon_aead128.rs index a487227f..cb8de15b 100644 --- a/crypto/ascon/src/ascon_aead128.rs +++ b/crypto/ascon/src/ascon_aead128.rs @@ -30,6 +30,7 @@ use bouncycastle_core::traits::{ AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, RNG, SecurityStrength, SuspendableKeyed, SymmetricCipherDecryptor, SymmetricCipherEncryptor, }; +use bouncycastle_modes::{Decrypting, Encrypting}; use bouncycastle_rng::HashDRBG_SHA512; use bouncycastle_utils::ct::ct_eq_bytes; use bouncycastle_utils::secret::Secret; @@ -687,6 +688,58 @@ impl AEADCipherDecryptor for AsconAead128D } } +/// Projects a direction marker onto the Ascon-AEAD128 type for that direction, which is what lets +/// [`Ascon_AEAD128`] take its direction as a parameter: a plain type alias cannot choose between two +/// distinct types, so it is written as a projection through this trait instead. +/// +/// Implemented for [`Encrypting`] and [`Decrypting`] and for nothing else, so those are the only +/// usable values of `Dir`. +pub trait AsconAead128Mode { + /// [`AsconAead128Encryptor`] or [`AsconAead128Decryptor`]. + type Mode; +} + +impl AsconAead128Mode for Encrypting { + type Mode = AsconAead128Encryptor; +} + +impl AsconAead128Mode for Decrypting { + type Mode = AsconAead128Decryptor; +} + +/// Ascon-AEAD128 (NIST SP 800-232), spelled as the specification spells it, in one direction: +/// `Ascon_AEAD128` is [`AsconAead128Encryptor`] and `Ascon_AEAD128` is +/// [`AsconAead128Decryptor`]. The wrong direction is a compile error, not a runtime check, and the +/// nonce is generated by encryption and returned, never supplied. +/// +/// Both directions implement [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] and, through them, +/// [`SymmetricCipherEncryptor`] / [`SymmetricCipherDecryptor`] -- which is the AEAD with no +/// associated data and the tag inline: +/// +/// ``` +/// use bouncycastle_ascon::Ascon_AEAD128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_core::traits::{SymmetricCipherDecryptor, SymmetricCipherEncryptor}; +/// use bouncycastle_modes::{Decrypting, Encrypting}; +/// +/// type Enc = Ascon_AEAD128; +/// type Dec = Ascon_AEAD128; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .expect("a 16-byte symmetric cipher key"); +/// +/// let message = b"hello"; +/// let mut ciphertext = [0u8; 5 + 16]; // Enc::encrypt_out_len(5): ciphertext || tag +/// let (nonce, written) = Enc::encrypt_out(&key, message, &mut ciphertext).expect("encryption"); +/// assert_eq!(written, 21); +/// +/// let mut plaintext = [0u8; 5]; // Dec::decrypt_out_max_len(21) +/// let n = Dec::decrypt_out(&key, &nonce, &ciphertext, &mut plaintext).expect("decryption"); +/// assert_eq!(&plaintext[..n], message); +/// ``` +#[allow(non_camel_case_types)] +pub type Ascon_AEAD128 = ::Mode; + impl Debug for AsconAead128 { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "AsconAead128 (key/state masked)") diff --git a/crypto/ascon/src/lib.rs b/crypto/ascon/src/lib.rs index 9f428857..78338887 100644 --- a/crypto/ascon/src/lib.rs +++ b/crypto/ascon/src/lib.rs @@ -168,6 +168,7 @@ mod permutation; mod sponge; pub mod ascon_aead128; +pub use ascon_aead128::Ascon_AEAD128; pub mod ascon_cxof128; pub mod ascon_hash256; pub mod ascon_xof128; diff --git a/crypto/ascon/tests/aead128_tests.rs b/crypto/ascon/tests/aead128_tests.rs index e055f55b..0edcc0e5 100644 --- a/crypto/ascon/tests/aead128_tests.rs +++ b/crypto/ascon/tests/aead128_tests.rs @@ -488,6 +488,23 @@ fn aead128_encryptor_decryptor_trait_framework() { .test_encryptor_decryptor::<16, 16, 16, 16, AsconAead128Encryptor, AsconAead128Decryptor>(); } +/// The same conformance suite through [`Ascon_AEAD128`], which must resolve to the same pair. +/// +/// [`Ascon_AEAD128`]: bouncycastle_ascon::Ascon_AEAD128 +#[test] +fn aead128_dir_alias_trait_framework() { + use bouncycastle_ascon::Ascon_AEAD128; + use bouncycastle_modes::{Decrypting, Encrypting}; + TestFrameworkAEADCipher::new().test_encryptor_decryptor::< + 16, + 16, + 16, + 16, + Ascon_AEAD128, + Ascon_AEAD128, + >(); +} + #[test] fn aead_framework_buffering_toy() { TestFrameworkAEADCipher::new().test_buffering_toy();