From b5fcf99252c22b3222bc51bacdb3e7e751516fed Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 9 Sep 2026 23:58:37 +0700 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 fb594fae74a562573cef6160754806e98576dab8 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Thu, 10 Sep 2026 16:17:50 +0700 Subject: [PATCH 15/26] Initial add of AES lightweight CCM mode (#125) --- cli/src/aes_ccm_cmd.rs | 329 ++++ cli/src/main.rs | 192 +++ cli/tests/aes_ccm_cli_tests.rs | 426 +++++ crypto/aes/src/ccm.rs | 242 +++ crypto/aes/src/lib.rs | 6 + crypto/aes/tests/bc-test-data.rs | 4 +- crypto/modes/benches/modes_benches.rs | 213 ++- crypto/modes/src/ccm.rs | 1495 ++++++++++++++++++ crypto/modes/src/lib.rs | 228 ++- crypto/modes/tests/acvp_ccm_tests.rs | 371 +++++ crypto/modes/tests/sp800_38c_tests.rs | 574 +++++++ mem_usage_benches/Cargo.toml | 4 + mem_usage_benches/src/bench_ccm_mem_usage.rs | 189 +++ mem_usage_benches/src/lib.rs | 1 + 14 files changed, 4236 insertions(+), 38 deletions(-) create mode 100644 cli/src/aes_ccm_cmd.rs create mode 100644 cli/tests/aes_ccm_cli_tests.rs create mode 100644 crypto/aes/src/ccm.rs create mode 100644 crypto/modes/src/ccm.rs create mode 100644 crypto/modes/tests/acvp_ccm_tests.rs create mode 100644 crypto/modes/tests/sp800_38c_tests.rs create mode 100644 mem_usage_benches/src/bench_ccm_mem_usage.rs diff --git a/cli/src/aes_ccm_cmd.rs b/cli/src/aes_ccm_cmd.rs new file mode 100644 index 00000000..a28489a7 --- /dev/null +++ b/cli/src/aes_ccm_cmd.rs @@ -0,0 +1,329 @@ +//! AES-CCM authenticated encryption and decryption (NIST SP 800-38C). +//! +//! # This command does not stream, and cannot +//! +//! Every other cipher command here streams stdin to stdout in 1 KiB chunks. This one reads stdin to +//! the end first, and that is a property of CCM rather than a shortcut. SP 800-38C Sec 3: +//! +//! > CCM is intended for use in a packet environment, i.e., when all of the data is available in +//! > storage before CCM is applied; CCM is not designed to support partial processing or stream +//! > processing. +//! +//! Appendix A.2.1 puts the payload's octet length inside `B0`, the first block the CBC-MAC absorbs, +//! so nothing can be authenticated until the whole payload length is known. Buffering the input is +//! therefore the correct behaviour, not a compromise -- and it has a real benefit on the decryption +//! side: unlike `ascon-aead128`, this command writes **no plaintext at all** until the tag has +//! verified, so a non-zero exit leaves nothing to discard. +//! +//! The practical consequence is that memory use is proportional to the input, so this is not the +//! command to point at a multi-gigabyte file. `aes256-ctr` piped through a separate MAC, or +//! `ascon-aead128`, are the streaming alternatives. +//! +//! # The nonce is supplied, not generated +//! +//! This is the one cipher command here with a `--nonce` flag. The other modes generate their IV or +//! nonce and prepend it to the output, because for them an unpredictable value is what is required. +//! CCM needs the nonce to be **unique**, not unpredictable -- Sec 5.3: "The nonce is not required +//! to be random" -- and a caller with a message counter can guarantee uniqueness better than a +//! DRBG draw can. Since a repeated nonce under one key is fatal for CCM (see the subcommand help), +//! the choice is the caller's to make explicitly. +//! +//! The nonce is not written to the output, so `encrypt` and `decrypt` both need the same +//! `--nonce`. +//! +//! # Lengths +//! +//! `--nonce` must be 7..=13 bytes and `--tag-len` one of 4, 6, 8, 10, 12, 14, 16, both from +//! Appendix A.1. The nonce length fixes the maximum payload at `2^(8 * (15 - n)) - 1` bytes, which +//! this command checks against the actual input length. Because those are const generic parameters +//! of the mode, the runtime value is dispatched to one of the seven nonce lengths and seven tag +//! lengths below. +//! +//! The output layout is Sec 6.1 step 8's own: `ciphertext || tag`. + +use std::io::{self, Read}; +use std::process::exit; + +use bouncycastle::aes::{AES_128, AES_192, AES_256}; +use bouncycastle::core::errors::SymmetricCipherError; +use bouncycastle::core::key_material::KeyMaterial; +use bouncycastle::core::traits::ElectronicCodeBook; +use bouncycastle::hex; +use bouncycastle::modes::{Ccm, Decrypting, Encrypting}; + +use crate::block_mode_cmd::{BLOCK_LEN, BlockModeAction, load_key}; +use crate::helpers; + +/// AES-128 CCM. See the module docs and the subcommand help. +pub(crate) fn aes128_ccm_cmd( + action: &BlockModeAction, + key: &Option, + key_file: &Option, + nonce: &Option, + nonce_file: &Option, + aad: &Option, + tag_len: usize, + output_hex: bool, +) { + run::( + action, + &load_key::<16>(key, key_file, "AES-128"), + nonce, + nonce_file, + aad, + tag_len, + output_hex, + ); +} + +/// AES-192 CCM. See [`aes128_ccm_cmd`]. +pub(crate) fn aes192_ccm_cmd( + action: &BlockModeAction, + key: &Option, + key_file: &Option, + nonce: &Option, + nonce_file: &Option, + aad: &Option, + tag_len: usize, + output_hex: bool, +) { + run::( + action, + &load_key::<24>(key, key_file, "AES-192"), + nonce, + nonce_file, + aad, + tag_len, + output_hex, + ); +} + +/// AES-256 CCM. See [`aes128_ccm_cmd`]. +pub(crate) fn aes256_ccm_cmd( + action: &BlockModeAction, + key: &Option, + key_file: &Option, + nonce: &Option, + nonce_file: &Option, + aad: &Option, + tag_len: usize, + output_hex: bool, +) { + run::( + action, + &load_key::<32>(key, key_file, "AES-256"), + nonce, + nonce_file, + aad, + tag_len, + output_hex, + ); +} + +/// Loads the nonce from `--nonce` (hex) or `--nonce-file` (hex or binary). +/// +/// Unlike the key there is no entropy question here: Sec 5.3 asks for uniqueness, not randomness, +/// so an all-zero nonce is a perfectly valid *first* nonce and only a repeat is a problem. +fn load_nonce(nonce: &Option, nonce_file: &Option) -> Vec { + let bytes = if let Some(file) = nonce_file { + helpers::read_from_file(file) + } else if let Some(v) = nonce { + hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: nonce is not valid hex."); + exit(-1) + }) + } else { + eprintln!("Error: --nonce or --nonce-file must be supplied. CCM has no generated nonce;"); + eprintln!(" see the subcommand help for why, and for the uniqueness requirement."); + exit(-1) + }; + + // Appendix A.1: "n is an element of {7, 8, 9, 10, 11, 12, 13}". + if !(7..=13).contains(&bytes.len()) { + eprintln!( + "Error: nonce is {} bytes; CCM requires 7 to 13 (SP 800-38C Appendix A.1).", + bytes.len() + ); + exit(-1) + } + bytes +} + +fn load_aad(aad: &Option) -> Vec { + match aad { + Some(v) => hex::decode(v).unwrap_or_else(|_| { + eprintln!("Error: associated data is not valid hex."); + exit(-1) + }), + None => Vec::new(), + } +} + +/// Reads all of stdin. See the module docs on why this is not a streaming command. +fn read_all_stdin() -> Vec { + let mut input = Vec::new(); + io::stdin().read_to_end(&mut input).expect("Failed to read from stdin"); + input +} + +/// Turns the runtime nonce and tag lengths into the mode's const generic parameters. +/// +/// `NONCE_LEN` and `TAG_LEN` are const parameters of `Ccm` -- that is what makes A.1's length +/// conditions compile-time checks rather than runtime ones -- so a command-line value has to be +/// matched into one of the permitted instantiations. The two nested matches are the price of that, +/// and they are exhaustive over A.1's sets: 7 nonce lengths x 7 tag lengths. +fn run( + action: &BlockModeAction, + key: &KeyMaterial, + nonce: &Option, + nonce_file: &Option, + aad: &Option, + tag_len: usize, + output_hex: bool, +) where + P: ElectronicCodeBook, +{ + let nonce_bytes = load_nonce(nonce, nonce_file); + let aad_bytes = load_aad(aad); + let input = read_all_stdin(); + let encrypt = matches!(action, BlockModeAction::Encrypt); + + // Appendix A.1: "t is an element of {4, 6, 8, 10, 12, 14, 16}". + macro_rules! with_tag_len { + ($n:literal) => { + match tag_len { + 4 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 6 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 8 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 10 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 12 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 14 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + 16 => go::( + key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + ), + other => { + eprintln!( + "Error: --tag-len is {other}; CCM requires one of 4, 6, 8, 10, 12, 14, 16 \ + (SP 800-38C Appendix A.1)." + ); + exit(-1) + } + } + }; + } + + // `load_nonce` has already rejected anything outside 7..=13, so the fall-through is unreachable; + // it is spelled out rather than `unreachable!()` so this cannot panic on a future edit. + match nonce_bytes.len() { + 7 => with_tag_len!(7), + 8 => with_tag_len!(8), + 9 => with_tag_len!(9), + 10 => with_tag_len!(10), + 11 => with_tag_len!(11), + 12 => with_tag_len!(12), + 13 => with_tag_len!(13), + other => { + eprintln!("Error: nonce is {other} bytes; CCM requires 7 to 13."); + exit(-1) + } + } +} + +/// One fully-instantiated CCM run. +fn go( + key: &KeyMaterial, + nonce_bytes: &[u8], + aad: &[u8], + input: &[u8], + encrypt: bool, + output_hex: bool, +) where + P: ElectronicCodeBook, +{ + type Enc = + Ccm; + type Dec = + Ccm; + + // `run` dispatched on this exact length, so the conversion cannot fail. + let Ok(nonce) = <[u8; NONCE_LEN]>::try_from(nonce_bytes) else { + eprintln!("Error: internal nonce length mismatch."); + exit(-1) + }; + + if encrypt { + let mut out = vec![0u8; input.len() + TAG_LEN]; + match Enc::::encrypt(key, &nonce, aad, input, &mut out) { + Ok(written) => { + helpers::write_bytes_or_hex(&out[..written], output_hex); + if output_hex { + println!(); + } + } + Err(SymmetricCipherError::GenericError(msg)) => { + // The only `GenericError` reachable here is the payload limit: A.1's `p < 2^8q`, + // where `q = 15 - n`. Report it with the numbers, since the fix is a shorter nonce. + eprintln!("Error: {msg}"); + eprintln!( + " Input is {} bytes; with a {NONCE_LEN}-byte nonce, q = {} and the \ + limit is {} bytes.", + input.len(), + 15 - NONCE_LEN, + payload_limit(15 - NONCE_LEN), + ); + eprintln!(" Use a shorter nonce for a larger payload."); + exit(-1) + } + Err(e) => { + eprintln!("Error: AES-CCM encryption failed: {e:?}"); + exit(-1) + } + } + } else { + if input.len() < TAG_LEN { + // Sec 6.2 step 1: "If Clen <= Tlen, then return INVALID". + eprintln!( + "Error: input is {} bytes, shorter than the {TAG_LEN}-byte tag it must end with.", + input.len() + ); + exit(-1) + } + let mut out = vec![0u8; input.len() - TAG_LEN]; + match Dec::::decrypt(key, &nonce, aad, input, &mut out) { + Ok(written) => { + helpers::write_bytes_or_hex(&out[..written], output_hex); + if output_hex { + println!(); + } + } + Err(SymmetricCipherError::AEADTagCheckFailed) => { + // Nothing has been written to stdout at this point, which is what buffering buys: + // Sec 6.2's "the payload P and the MAC T shall not be revealed" holds end to end. + eprintln!("Error: AES-CCM authentication failed; the input is not authentic."); + exit(-1) + } + Err(e) => { + eprintln!("Error: AES-CCM decryption failed: {e:?}"); + exit(-1) + } + } + } +} + +/// A.1's `2^8q - 1`, for the error message above. Saturates at `u64::MAX` for `q = 8`, where the +/// bound is beyond any real input anyway. +fn payload_limit(q: usize) -> u64 { + if q >= 8 { u64::MAX } else { (1u64 << (8 * q)) - 1 } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 1a0fe0fd..6be91cb3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,5 @@ mod aes_cbc_cmd; +mod aes_ccm_cmd; mod aes_cfb8_cmd; mod aes_cfb_cmd; mod aes_ctr_cmd; @@ -966,6 +967,155 @@ enum Subcommands { x: bool, }, + /// AES-128 in CCM mode (NIST SP 800-38C): authenticated encryption of stdin to stdout. + /// + /// CCM is an AEAD: it protects both confidentiality and authenticity, and `decrypt` either + /// writes the plaintext or fails, unlike aes*-cbc/-cfb/-ctr, which cannot detect tampering. + /// + /// The output of `encrypt` is `ciphertext || tag` -- SP 800-38C Sec 6.1 step 8's own layout -- + /// so it is `--tag-len` bytes longer than the input, and `decrypt` reads the tag back off the + /// end. Both directions authenticate `--aad` as well as the payload. + /// + /// THE NONCE IS SUPPLIED, NOT GENERATED, and this is the only cipher command here that takes + /// one. The other modes need an unpredictable IV, so they generate it; CCM needs the nonce to + /// be UNIQUE but not unpredictable (Sec 5.3: "The nonce is not required to be random"), and a + /// caller with a message counter can guarantee uniqueness better than a random draw. The nonce + /// is NOT written to the output, so `decrypt` needs the same `--nonce` as `encrypt`. + /// + /// WARNING: never reuse a nonce under one key. For CCM a repeat is worse than for CTR: it + /// reuses the keystream AND lets an attacker who can replay the nonce flip any chosen bit of + /// the payload (Appendix B.1). Use a counter, or a random value long enough that a collision is + /// negligible. + /// + /// Nonce length must be 7 to 13 bytes and `--tag-len` one of 4, 6, 8, 10, 12, 14, 16 + /// (Appendix A.1). The two are linked to the payload limit and the forgery bound respectively: + /// a nonce of n bytes caps the payload at 2^(8*(15-n)) - 1 bytes, so 13 bytes allows only + /// 64 KiB - 1 while 7 bytes is effectively unlimited; and Sec B.2 says a tag shorter than + /// 8 bytes "shall not be used without a careful analysis of the risks". A 12-byte nonce with a + /// 16-byte tag is the usual choice and the default. + /// + /// UNLIKE EVERY OTHER CIPHER COMMAND HERE, THIS ONE DOES NOT STREAM: it reads all of stdin + /// before doing any work, so memory use is proportional to the input. That is inherent to CCM, + /// not a limitation of this implementation -- Sec 3: "CCM is not designed to support partial + /// processing or stream processing", because Appendix A.2.1 puts the payload length inside the + /// first block the MAC covers. It does buy one thing: on `decrypt` NO plaintext is written + /// until the tag has verified, so unlike `ascon-aead128` a non-zero exit leaves nothing to + /// discard. For large inputs use `ascon-aead128`, which streams. + /// + /// Input may be any length: CCM pads internally and the payload is not block-aligned. + /// + /// 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. + AES128_CCM { + action: BlockModeAction, + + /// The 16-byte AES 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 16-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + /// The nonce in hex, 7 to 13 bytes. MUST be unique per encryption under a given key. + #[arg(long)] + nonce: Option, + + /// A file containing the nonce, in hex or binary. + #[arg(long)] + nonce_file: Option, + + /// Associated data in hex: authenticated but not encrypted. Must match on decrypt. + #[arg(long)] + aad: Option, + + /// Tag length in bytes: one of 4, 6, 8, 10, 12, 14, 16. Must match on decrypt. + #[arg(long, default_value_t = 16)] + tag_len: usize, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-192 in CCM mode (NIST SP 800-38C), authenticated encryption of stdin to stdout. + /// + /// See `aes128-ccm` for the nonce convention, the length rules, the non-streaming note and the + /// warnings; only the key length differs. + AES192_CCM { + action: BlockModeAction, + + /// The 24-byte AES 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 24-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + /// The nonce in hex, 7 to 13 bytes. MUST be unique per encryption under a given key. + #[arg(long)] + nonce: Option, + + /// A file containing the nonce, in hex or binary. + #[arg(long)] + nonce_file: Option, + + /// Associated data in hex: authenticated but not encrypted. Must match on decrypt. + #[arg(long)] + aad: Option, + + /// Tag length in bytes: one of 4, 6, 8, 10, 12, 14, 16. Must match on decrypt. + #[arg(long, default_value_t = 16)] + tag_len: usize, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-256 in CCM mode (NIST SP 800-38C), authenticated encryption of stdin to stdout. + /// + /// See `aes128-ccm` for the nonce convention, the length rules, the non-streaming note and the + /// warnings; only the key length differs. + AES256_CCM { + action: BlockModeAction, + + /// The 32-byte AES 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 32-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + /// The nonce in hex, 7 to 13 bytes. MUST be unique per encryption under a given key. + #[arg(long)] + nonce: Option, + + /// A file containing the nonce, in hex or binary. + #[arg(long)] + nonce_file: Option, + + /// Associated data in hex: authenticated but not encrypted. Must match on decrypt. + #[arg(long)] + aad: Option, + + /// Tag length in bytes: one of 4, 6, 8, 10, 12, 14, 16. Must match on decrypt. + #[arg(long, default_value_t = 16)] + tag_len: usize, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + /// AES-128 in ECB mode (NIST SP 800-38A Sec 6.1), streaming stdin to stdout. /// /// WARNING: ECB is NOT a confidentiality mode for data. Under a given key every plaintext @@ -1443,6 +1593,48 @@ fn run() { Some(Subcommands::AES256_CTR { action, key, key_file, x }) => { aes_ctr_cmd::aes256_ctr_cmd(action, key, key_file, *x); } + Some(Subcommands::AES128_CCM { + action, + key, + key_file, + nonce, + nonce_file, + aad, + tag_len, + x, + }) => { + aes_ccm_cmd::aes128_ccm_cmd( + action, key, key_file, nonce, nonce_file, aad, *tag_len, *x, + ); + } + Some(Subcommands::AES192_CCM { + action, + key, + key_file, + nonce, + nonce_file, + aad, + tag_len, + x, + }) => { + aes_ccm_cmd::aes192_ccm_cmd( + action, key, key_file, nonce, nonce_file, aad, *tag_len, *x, + ); + } + Some(Subcommands::AES256_CCM { + action, + key, + key_file, + nonce, + nonce_file, + aad, + tag_len, + x, + }) => { + aes_ccm_cmd::aes256_ccm_cmd( + action, key, key_file, nonce, nonce_file, aad, *tag_len, *x, + ); + } Some(Subcommands::AES128_ECB { action, key, key_file, x }) => { aes_ecb_cmd::aes128_ecb_cmd(action, key, key_file, *x); } diff --git a/cli/tests/aes_ccm_cli_tests.rs b/cli/tests/aes_ccm_cli_tests.rs new file mode 100644 index 00000000..ca0ca6de --- /dev/null +++ b/cli/tests/aes_ccm_cli_tests.rs @@ -0,0 +1,426 @@ +//! Tests for the `aes128-ccm` / `aes192-ccm` / `aes256-ccm` subcommands. +//! +//! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is +//! the command-line contract itself -- the supplied nonce, the AAD flag, the tag riding at the end +//! of the ciphertext, the exit code on a failed tag check -- none of which is reachable from the +//! library API. +//! +//! Key loading is shared with `aes*-cbc` (`cli/src/block_mode_cmd.rs`), so that coverage is +//! repeated here rather than assumed. What is tested only here is everything CCM does differently +//! from the other five modes: +//! +//! * the **nonce is a required flag** and is *not* written to the output, unlike every other mode's +//! generated IV; +//! * `--aad` is authenticated but not encrypted, and must match on both sides; +//! * `--tag-len` changes the output length, and must match on both sides; +//! * `decrypt` **fails with a non-zero exit and writes nothing** when the input is inauthentic; +//! * the nonce length and tag length are validated against SP 800-38C Appendix A.1, and the nonce +//! length caps the payload. +//! +//! The known-answer test is SP 800-38C Appendix C.1, run end to end through the pipe, so the CLI is +//! pinned against the specification and not merely against itself. +//! +//! `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"); + +const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; + +/// A 12-byte nonce, the length these tests use unless they are about nonce length. +const NONCE: &str = "000102030405060708090a0b"; + +/// Runs `bc-rust ` with `stdin_bytes` on stdin. See `aes_ctr_cli_tests.rs` for why stdin +/// is written from a separate thread and why `BrokenPipe` is ignored; the reasoning is identical. +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}"), + }); + + let output = child.wait_with_output().expect("failed to wait for bc-rust"); + writer.join().expect("the stdin writer thread panicked"); + output +} + +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 +} + +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: {} bytes", + out.stdout.len() + ); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(s: &str) -> Vec { + assert!(s.len().is_multiple_of(2), "hex must be an even number of characters"); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() +} + +/// SP 800-38C Appendix C.1, end to end: `Klen = 128, Tlen = 32, Nlen = 56, Alen = 64, Plen = 32`. +/// +/// The appendix's `C` is `7162015b 4dac255d`, which is the 4-byte ciphertext followed by the 4-byte +/// tag -- exactly what this command writes. This is the one test here that pins the CLI against the +/// specification rather than against a round trip. +#[test] +fn encrypt_matches_sp800_38c_appendix_c1() { + let out = run_ok( + &[ + "aes128-ccm", + "encrypt", + "--key", + "404142434445464748494a4b4c4d4e4f", + "--nonce", + "10111213141516", + "--aad", + "0001020304050607", + "--tag-len", + "4", + ], + &unhex("20212223"), + ); + assert_eq!(hex(&out), "7162015b4dac255d", "Appendix C.1's C string"); + + // And back again. The appendix gives no decryption example, but says one is "straightforward to + // construct" from each. + let back = run_ok( + &[ + "aes128-ccm", + "decrypt", + "--key", + "404142434445464748494a4b4c4d4e4f", + "--nonce", + "10111213141516", + "--aad", + "0001020304050607", + "--tag-len", + "4", + ], + &out, + ); + assert_eq!(hex(&back), "20212223", "Appendix C.1's P"); +} + +/// A round trip at each key length, with AAD, over a payload that spans several blocks and does not +/// end on a block boundary. +#[test] +fn encrypt_then_decrypt_round_trips() { + let plaintext: Vec = (0..=200u8).collect(); + for (cmd, key) in [("aes128-ccm", KEY_128), ("aes192-ccm", KEY_192), ("aes256-ccm", KEY_256)] { + let sealed = run_ok( + &[cmd, "encrypt", "--key", key, "--nonce", NONCE, "--aad", "cafebabe"], + &plaintext, + ); + assert_eq!( + sealed.len(), + plaintext.len() + 16, + "{cmd}: the default tag length is 16, and the nonce is not written" + ); + let opened = + run_ok(&[cmd, "decrypt", "--key", key, "--nonce", NONCE, "--aad", "cafebabe"], &sealed); + assert_eq!(opened, plaintext, "{cmd}: round trip"); + } +} + +/// The three commands are not interchangeable: a ciphertext from one must not decrypt under +/// another, even with the right-length key, and the failure is the tag check rather than garbage. +#[test] +fn the_three_variants_are_not_interchangeable() { + let sealed = + run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], b"a short message"); + let stderr = run_err(&["aes256-ccm", "decrypt", "--key", KEY_256, "--nonce", NONCE], &sealed); + assert!( + stderr.contains("authentication failed"), + "expected a tag-check failure, got: {stderr}" + ); +} + +/// The nonce is **not** written to the output, so `decrypt` needs the same `--nonce`. This is the +/// sharpest difference from the other five commands, all of which prepend their generated IV. +#[test] +fn the_nonce_is_not_written_to_the_output_and_is_required_to_decrypt() { + let plaintext = b"the nonce rides out of band"; + let sealed = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], plaintext); + assert_eq!( + sealed.len(), + plaintext.len() + 16, + "output is plaintext + tag only; no nonce prefix" + ); + + // A different nonce must fail: it changes both B0 and every counter block. + let mut other = unhex(NONCE); + other[0] ^= 1; + let stderr = + run_err(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", &hex(&other)], &sealed); + assert!(stderr.contains("authentication failed"), "got: {stderr}"); +} + +/// Omitting the nonce is refused, and the message says why there is no generated one. +#[test] +fn a_missing_nonce_is_rejected_with_an_explanation() { + let stderr = run_err(&["aes128-ccm", "encrypt", "--key", KEY_128], b"data"); + assert!(stderr.contains("--nonce"), "stderr should name the flag: {stderr}"); + assert!( + stderr.contains("no generated nonce"), + "stderr should say why there is no generated nonce: {stderr}" + ); +} + +/// The AAD is authenticated but not encrypted: it does not change the ciphertext length, it does +/// change the tag, and a mismatch on decryption is caught. +#[test] +fn the_aad_is_authenticated_but_not_encrypted() { + let plaintext = b"payload"; + let with = run_ok( + &["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE, "--aad", "0011"], + plaintext, + ); + let without = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], plaintext); + + assert_eq!(with.len(), without.len(), "AAD does not change the output length"); + assert_eq!( + with[..plaintext.len()], + without[..plaintext.len()], + "AAD does not change the ciphertext, only the tag" + ); + assert_ne!(with[plaintext.len()..], without[plaintext.len()..], "AAD changes the tag"); + + // Wrong AAD, missing AAD and extra AAD must all be caught. + for args in [ + vec!["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE, "--aad", "0012"], + vec!["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE], + vec!["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE, "--aad", "001100"], + ] { + let stderr = run_err(&args, &with); + assert!(stderr.contains("authentication failed"), "{args:?} gave: {stderr}"); + } +} + +/// A failed tag check must exit non-zero **and write nothing**. This is what buffering the input +/// buys, and it is stronger than `ascon-aead128`'s contract; SP 800-38C Sec 6.2 requires that on +/// INVALID "the payload P and the MAC T shall not be revealed". +#[test] +fn a_tampered_ciphertext_produces_no_output_at_all() { + let plaintext: Vec = (0..=255u8).collect(); + let sealed = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], &plaintext); + + // Flip a bit in the ciphertext, then in the tag; both must be caught with empty stdout. + for pos in [0usize, plaintext.len() - 1, plaintext.len(), sealed.len() - 1] { + let mut bad = sealed.clone(); + bad[pos] ^= 0x01; + let out = run(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE], &bad); + assert!(!out.status.success(), "a flipped bit at {pos} must fail"); + assert!( + out.stdout.is_empty(), + "no plaintext may be written when the tag check fails (flipped byte {pos}), \ + got {} bytes", + out.stdout.len() + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("authentication failed"), + "flipped byte {pos}" + ); + } +} + +/// `--tag-len` changes the output length and must match on both sides, and only A.1's values are +/// accepted. +#[test] +fn tag_len_is_validated_and_must_match() { + let plaintext = b"tag length matters"; + + for t in [4usize, 6, 8, 10, 12, 14, 16] { + let t_str = t.to_string(); + let sealed = run_ok( + &["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE, "--tag-len", &t_str], + plaintext, + ); + assert_eq!(sealed.len(), plaintext.len() + t, "tag-len {t}"); + let opened = run_ok( + &["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE, "--tag-len", &t_str], + &sealed, + ); + assert_eq!(opened, plaintext, "tag-len {t} round trip"); + } + + // A.1: t is an element of {4, 6, 8, 10, 12, 14, 16}. Odd values and out-of-range are refused. + for bad in ["0", "2", "5", "15", "17", "32"] { + let stderr = run_err( + &["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE, "--tag-len", bad], + b"data", + ); + assert!(stderr.contains("tag-len"), "tag-len {bad} gave: {stderr}"); + assert!(stderr.contains("A.1"), "the message should cite A.1: {stderr}"); + } + + // A tag-len mismatch between the two sides is caught rather than silently truncating. + let sealed = run_ok( + &["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE, "--tag-len", "16"], + plaintext, + ); + let stderr = run_err( + &["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE, "--tag-len", "8"], + &sealed, + ); + assert!(stderr.contains("authentication failed"), "got: {stderr}"); +} + +/// Every nonce length A.1 permits works, and nothing else does. The nonce length is not written +/// anywhere, so both sides must agree on it too. +#[test] +fn nonce_len_is_validated_across_a_1_s_whole_range() { + let plaintext = b"nonce lengths"; + + for n in 7usize..=13 { + let nonce = hex(&vec![0x5Au8; n]); + let sealed = + run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", &nonce], plaintext); + let opened = + run_ok(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", &nonce], &sealed); + assert_eq!(opened, plaintext, "nonce length {n}"); + } + + // A.1: n is an element of {7, ..., 13}. + for n in [0usize, 1, 6, 14, 16] { + let nonce = hex(&vec![0x5Au8; n]); + let stderr = + run_err(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", &nonce], b"data"); + assert!( + stderr.contains("7 to 13"), + "nonce length {n} should be refused with the range: {stderr}" + ); + } +} + +/// The nonce length caps the payload (A.1's `p < 2^8q`, `q = 15 - n`), and the error says so with +/// the numbers rather than just failing. +#[test] +fn a_payload_past_the_q_limit_is_rejected_with_the_numbers() { + // n = 13 gives q = 2, so the limit is 65535 bytes. + let nonce = hex(&[0x5Au8; 13]); + let too_big = vec![0u8; 65536]; + let stderr = run_err(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", &nonce], &too_big); + assert!(stderr.contains("65535"), "the message should give the limit: {stderr}"); + assert!(stderr.contains("65536"), "and the actual input length: {stderr}"); + + // One byte under the limit is fine, which pins the boundary rather than just the rejection. + let ok = vec![0u8; 65535]; + let sealed = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", &nonce], &ok); + assert_eq!(sealed.len(), 65535 + 16); +} + +/// Sec 6.2 step 1: a `C` too short to contain a tag is rejected before anything else. +#[test] +fn an_input_shorter_than_the_tag_is_rejected() { + for len in [0usize, 1, 15] { + let stderr = run_err( + &["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE], + &vec![0u8; len], + ); + assert!( + stderr.contains("shorter than"), + "a {len}-byte input should be refused as too short: {stderr}" + ); + } + + // Exactly the tag length is an empty payload plus its tag, which is valid (Sec 5.3 footnote). + let sealed = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], b""); + assert_eq!(sealed.len(), 16); + let opened = run_ok(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", NONCE], &sealed); + assert!(opened.is_empty(), "an empty payload round trips to nothing"); +} + +/// `-x` writes hex, and it must be the hex of what the binary form writes. +#[test] +fn hex_output_matches_binary_output() { + let plaintext = b"hex and binary"; + let binary = run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE], plaintext); + let as_hex = + run_ok(&["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce", NONCE, "-x"], plaintext); + assert_eq!(String::from_utf8_lossy(&as_hex).trim(), hex(&binary)); +} + +/// Key loading errors are the shared `block_mode_cmd` ones, checked here so the CCM commands are +/// not assumed to inherit them. +#[test] +fn a_key_of_the_wrong_length_is_rejected() { + let stderr = run_err(&["aes128-ccm", "encrypt", "--key", KEY_256, "--nonce", NONCE], b"data"); + assert!(!stderr.is_empty(), "a 32-byte key must be refused by aes128-ccm"); + + let stderr = run_err(&["aes128-ccm", "encrypt", "--nonce", NONCE], b"data"); + assert!(stderr.contains("key"), "stderr should mention the key options: {stderr}"); +} + +/// An input larger than a pipe buffer round trips, which also pins that the non-streaming +/// read-all-of-stdin loop does not deadlock against its own output. +#[test] +fn a_payload_larger_than_the_pipe_buffer_round_trips() { + // 256 KiB, comfortably past the usual 64 KiB pipe buffer. A 12-byte nonce gives q = 3, so the + // payload limit is 16 MiB and this is well inside it. + let plaintext: Vec = (0..256 * 1024).map(|i| (i % 251) as u8).collect(); + let sealed = run_ok(&["aes256-ccm", "encrypt", "--key", KEY_256, "--nonce", NONCE], &plaintext); + assert_eq!(sealed.len(), plaintext.len() + 16); + let opened = run_ok(&["aes256-ccm", "decrypt", "--key", KEY_256, "--nonce", NONCE], &sealed); + assert_eq!(opened, plaintext); +} + +/// The subcommands are listed in `--help`, and their own help documents the things that differ from +/// the other modes: the supplied nonce, the non-streaming behaviour, and the nonce-reuse hazard. +#[test] +fn the_subcommands_are_documented_in_help() { + let help = String::from_utf8_lossy(&run_ok(&["--help"], b"")).into_owned(); + for cmd in ["aes128-ccm", "aes192-ccm", "aes256-ccm"] { + assert!(help.contains(cmd), "{cmd} should be listed in --help"); + } + + let per_cmd = String::from_utf8_lossy(&run_ok(&["aes128-ccm", "--help"], b"")).into_owned(); + assert!( + per_cmd.contains("NOT GENERATED") || per_cmd.contains("SUPPLIED"), + "the help should say the nonce is supplied: {per_cmd}" + ); + assert!( + per_cmd.to_lowercase().contains("does not stream"), + "the help should say it does not stream: {per_cmd}" + ); + assert!( + per_cmd.contains("never reuse a nonce"), + "the help should warn about nonce reuse: {per_cmd}" + ); +} diff --git a/crypto/aes/src/ccm.rs b/crypto/aes/src/ccm.rs new file mode 100644 index 00000000..f0849d24 --- /dev/null +++ b/crypto/aes/src/ccm.rs @@ -0,0 +1,242 @@ +//! Type aliases for AES in CCM mode (NIST SP 800-38C). +//! +//! `bouncycastle-modes` is deliberately cipher-agnostic, so `Ccm` takes the permutation and the +//! `KEY_LEN` / `BLOCK_LEN` / `NONCE_LEN` / `TAG_LEN` const parameters. These aliases pin the AES +//! values so callers never spell them out. They add nothing to the engine: the permutation still +//! implements none of the data-encryption traits itself (see the crate docs), the mode does. +//! +//! AES is the *only* cipher CCM can use. SP 800-38C Sec 3: "CCM is based on an approved symmetric +//! key block cipher algorithm whose block size is 128 bits ... thus, CCM cannot be used with the +//! Triple Data Encryption Algorithm, whose block size is 64 bits", and Sec 5.1 adds that +//! "currently, the AES algorithm is the only approved block cipher algorithm with this block size". +//! +//! # The nonce length and the tag length stay parameters +//! +//! `Dir` is [`Encrypting`](bouncycastle_modes::Encrypting) or +//! [`Decrypting`](bouncycastle_modes::Decrypting), as for the other modes. Beyond that, and unlike +//! the other aliases in this crate, these do not pin everything: `NONCE_LEN` and `TAG_LEN` +//! are real cryptographic choices, and CCM ties them to the payload limit and to the strength of +//! the authentication respectively, so hiding them behind a default would hide the decision: +//! +//! * **`NONCE_LEN` (the spec's `n`) fixes the maximum payload.** A.1 requires `n + q = 15`, and +//! `q` bounds the payload at `2^8q - 1` bytes. So a 13-byte nonce caps a message at 64 KiB - 1, +//! and a 7-byte nonce lifts the cap entirely at the cost of nonce space. See +//! [`Ccm`](bouncycastle_modes::Ccm) for the table. +//! * **`TAG_LEN` (the spec's `t`) is the forgery bound.** Sec B.2: "a value of Tlen that is less +//! than 64 shall not be used without a careful analysis of the risks of accepting inauthentic +//! data as authentic". +//! +//! Both are still checked at compile time against A.1's permitted sets, so a wrong value is a +//! compile error rather than a runtime `Err`. +//! +//! [`CCM_NONCE_LEN`] and [`CCM_TAG_LEN`] name the sensible default pair -- a 12-byte nonce and a +//! 16-byte tag, which is what the NIST ACVP vectors and most protocols use -- for callers who have +//! no reason to choose otherwise: +//! +//! ```text +//! AES_CCM_128 // 12-byte nonce, 16-byte tag, < 16 MiB +//! AES_CCM_128 // IEEE 802.11 CCMP's pair +//! ``` +//! +//! # Streaming needs the buffering pair +//! +//! These aliases are for [`Ccm`](bouncycastle_modes::Ccm) itself: its one-shots and its +//! length-declared streaming API, neither of which buffers. Code written against +//! [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] wants +//! [`AES_CCM_128_Encryptor`] / [`AES_CCM_128_Decryptor`] instead, which carry the extra +//! `BUFFER_LEN` those traits force; see [`CcmEncryptor`](bouncycastle_modes::CcmEncryptor) for why. + +use crate::{AES_128, AES_192, AES_256, BLOCK_LEN}; +use bouncycastle_modes::{Ccm, CcmDecryptor, CcmEncryptor}; + +// Imports needed for docs +#[allow(unused_imports)] +use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +// end of imports needed for docs + +/// The nonce length to use unless there is a reason not to: 12 bytes, which is what the NIST ACVP +/// `ACVP-AES-CCM` vectors use in every group. It leaves `q = 3`, so a payload of up to +/// 16 MiB - 1 bytes. +pub const CCM_NONCE_LEN: usize = 12; + +/// The tag length to use unless there is a reason not to: the full 16 bytes, the largest A.1 +/// permits. See the module docs on Sec B.2. +pub const CCM_TAG_LEN: usize = 16; + +/// AES-128 in CCM mode (SP 800-38C). +/// +/// `NONCE_LEN` must be 7..=13 and `TAG_LEN` one of 4, 6, 8, 10, 12, 14, 16 (A.1); anything else is +/// a compile error. Use [`CCM_NONCE_LEN`] and [`CCM_TAG_LEN`] if you have no reason to choose. +/// +/// The nonce is **supplied**, not generated, because CCM requires it to be unique but not random +/// (Sec 5.3), so a caller with a counter can do better than a draw from a DRBG. It must never +/// repeat under one key; see [`Ccm`]'s security considerations. +/// +/// ``` +/// use bouncycastle_aes::{AES_CCM_128, CCM_NONCE_LEN, CCM_TAG_LEN}; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Decrypting, Encrypting}; +/// +/// type Ccm128 = AES_CCM_128; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .expect("a 16-byte symmetric cipher key"); +/// let nonce = [0x01u8; CCM_NONCE_LEN]; +/// let header = b"authenticated but not encrypted"; +/// let message = b"authenticated and encrypted"; +/// +/// // The spec's own layout: ciphertext with the tag appended (Sec 6.1 step 8). +/// let mut sealed = vec![0u8; message.len() + CCM_TAG_LEN]; +/// let n = Ccm128::::encrypt(&key, &nonce, header, message, &mut sealed).expect("encryption"); +/// assert_eq!(n, sealed.len()); +/// +/// let mut opened = vec![0u8; message.len()]; +/// let n = Ccm128::::decrypt(&key, &nonce, header, &sealed, &mut opened).expect("decryption"); +/// assert_eq!(&opened[..n], message); +/// +/// // Tampering with either the ciphertext or the header is caught. +/// let mut tampered = sealed.clone(); +/// tampered[0] ^= 1; +/// assert!(Ccm128::::decrypt(&key, &nonce, header, &tampered, &mut opened).is_err()); +/// assert!(Ccm128::::decrypt(&key, &nonce, b"other header", &sealed, &mut opened).is_err()); +/// ``` +/// +/// A detached tag, for a wire format that carries it separately: +/// +/// ``` +/// use bouncycastle_aes::{AES_CCM_128, CCM_NONCE_LEN, CCM_TAG_LEN}; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Decrypting, Encrypting}; +/// +/// type Ccm128 = AES_CCM_128; +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let nonce = [0x02u8; CCM_NONCE_LEN]; +/// let message = b"a short packet"; +/// +/// let mut ct = vec![0u8; message.len()]; +/// let (n, tag) = Ccm128::::encrypt_detached(&key, &nonce, &[], message, &mut ct).unwrap(); +/// assert_eq!(n, message.len(), "CCM never expands the payload"); +/// +/// let mut pt = vec![0u8; message.len()]; +/// Ccm128::::decrypt_detached(&key, &nonce, &[], &ct, &tag, &mut pt).unwrap(); +/// assert_eq!(&pt[..], message); +/// ``` +#[allow(non_camel_case_types)] +pub type AES_CCM_128 = + Ccm; + +/// AES-192 in CCM mode. See [`AES_CCM_128`]. +/// +/// ``` +/// use bouncycastle_aes::{AES_CCM_192, CCM_NONCE_LEN, CCM_TAG_LEN}; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Decrypting, Encrypting}; +/// +/// type Ccm192 = AES_CCM_192; +/// let key = KeyMaterial::<24>::from_bytes_as_type(&[0x42; 24], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let nonce = [0x03u8; CCM_NONCE_LEN]; +/// let message = [0u8; 30]; +/// +/// let mut sealed = vec![0u8; message.len() + CCM_TAG_LEN]; +/// Ccm192::::encrypt(&key, &nonce, &[], &message, &mut sealed).unwrap(); +/// let mut opened = vec![0u8; message.len()]; +/// Ccm192::::decrypt(&key, &nonce, &[], &sealed, &mut opened).unwrap(); +/// assert_eq!(opened, message); +/// ``` +#[allow(non_camel_case_types)] +pub type AES_CCM_192 = + Ccm; + +/// AES-256 in CCM mode. See [`AES_CCM_128`]. +/// +/// ``` +/// use bouncycastle_aes::{AES_CCM_256, CCM_NONCE_LEN, CCM_TAG_LEN}; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Decrypting, Encrypting}; +/// +/// type Ccm256 = AES_CCM_256; +/// let key = KeyMaterial::<32>::from_bytes_as_type(&[0x42; 32], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let nonce = [0x04u8; CCM_NONCE_LEN]; +/// let message = [0u8; 30]; +/// +/// let mut sealed = vec![0u8; message.len() + CCM_TAG_LEN]; +/// Ccm256::::encrypt(&key, &nonce, &[], &message, &mut sealed).unwrap(); +/// let mut opened = vec![0u8; message.len()]; +/// Ccm256::::decrypt(&key, &nonce, &[], &sealed, &mut opened).unwrap(); +/// assert_eq!(opened, message); +/// ``` +#[allow(non_camel_case_types)] +pub type AES_CCM_256 = + Ccm; + +/// AES-128 CCM as an [`AEADCipherEncryptor`], for code written against the generic AEAD trait. +/// +/// `BUFFER_LEN` is the largest message and the largest AAD this will accept, and is also the +/// trait's `FINAL_LEN`. It exists because the trait's `do_encrypt_init` is handed no length and CCM +/// needs one; see [`CcmEncryptor`]. The nonce is generated here, unlike [`AES_CCM_128`]'s, because +/// the trait generates it. +/// +/// ``` +/// use bouncycastle_aes::{AES_CCM_128_Decryptor, AES_CCM_128_Encryptor}; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +/// +/// // 2 KiB is comfortably above an 802.11 frame, the packet size CCM was designed for. +/// type Enc = AES_CCM_128_Encryptor<12, 16, 2048>; +/// type Dec = AES_CCM_128_Decryptor<12, 16, 2048>; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let (nonce, ciphertext, tag) = Enc::encrypt(&key, b"header", b"message").unwrap(); +/// let plaintext = Dec::decrypt(&key, &nonce, b"header", &ciphertext, &tag).unwrap(); +/// assert_eq!(plaintext, b"message"); +/// ``` +#[allow(non_camel_case_types)] +pub type AES_CCM_128_Encryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmEncryptor; + +/// AES-128 CCM as an [`AEADCipherDecryptor`]. See [`AES_CCM_128_Encryptor`]. +#[allow(non_camel_case_types)] +pub type AES_CCM_128_Decryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmDecryptor; + +/// AES-192 CCM as an [`AEADCipherEncryptor`]. See [`AES_CCM_128_Encryptor`]. +#[allow(non_camel_case_types)] +pub type AES_CCM_192_Encryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmEncryptor; + +/// AES-192 CCM as an [`AEADCipherDecryptor`]. See [`AES_CCM_128_Encryptor`]. +#[allow(non_camel_case_types)] +pub type AES_CCM_192_Decryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmDecryptor; + +/// AES-256 CCM as an [`AEADCipherEncryptor`]. See [`AES_CCM_128_Encryptor`]. +#[allow(non_camel_case_types)] +pub type AES_CCM_256_Encryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmEncryptor; + +/// AES-256 CCM as an [`AEADCipherDecryptor`]. See [`AES_CCM_128_Encryptor`]. +#[allow(non_camel_case_types)] +pub type AES_CCM_256_Decryptor< + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> = CcmDecryptor; diff --git a/crypto/aes/src/lib.rs b/crypto/aes/src/lib.rs index b764be9c..783141a4 100644 --- a/crypto/aes/src/lib.rs +++ b/crypto/aes/src/lib.rs @@ -220,6 +220,7 @@ mod aes; mod bitslice; mod cbc; +mod ccm; mod cfb; mod cfb8; mod ctr; @@ -231,6 +232,11 @@ mod schedule; pub use aes::{AES_128, AES_192, AES_256, BLOCK_LEN}; pub use cbc::{AES_CBC_128, AES_CBC_192, AES_CBC_256}; +pub use ccm::{ + AES_CCM_128, AES_CCM_128_Decryptor, AES_CCM_128_Encryptor, AES_CCM_192, AES_CCM_192_Decryptor, + AES_CCM_192_Encryptor, AES_CCM_256, AES_CCM_256_Decryptor, AES_CCM_256_Encryptor, + CCM_NONCE_LEN, CCM_TAG_LEN, +}; pub use cfb::{AES_CFB_128, AES_CFB_192, AES_CFB_256}; pub use cfb8::{AES_CFB8_128, AES_CFB8_192, AES_CFB8_256}; pub use ctr::{AES_CTR_128, AES_CTR_192, AES_CTR_256, CTR_NONCE_LEN}; diff --git a/crypto/aes/tests/bc-test-data.rs b/crypto/aes/tests/bc-test-data.rs index c94df200..ee41918a 100644 --- a/crypto/aes/tests/bc-test-data.rs +++ b/crypto/aes/tests/bc-test-data.rs @@ -11,7 +11,7 @@ //! block-permutation test vector -- which is the only reason ECB is mentioned in this crate. See //! the crate docs on why you must never use ECB to encrypt data. //! -//! `bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This file deliberately +//! `bc-test-data` ships sixteen ACVP AES vector sets, one per mode. This file deliberately //! consumes only `ACVP-AES-ECB`, because that is the one that tests the permutation rather than a //! mode. The others belong with whatever implements the mode: //! @@ -20,10 +20,12 @@ //! | `ACVP-AES-ECB` | this file (the permutation) and `crypto/modes/tests/acvp_ecb_tests.rs` (the `Ecb` mode) | //! | `ACVP-AES-CBC` | `crypto/modes/tests/acvp_tests.rs` | //! | `ACVP-AES-CBC-CS1` / `-CS2` / `-CS3` | nothing yet (ciphertext stealing is unimplemented) | +//! | `ACVP-AES-CCM` | `crypto/modes/tests/acvp_ccm_tests.rs` | //! | `ACVP-AES-CFB128` | `crypto/modes/tests/acvp_cfb_tests.rs` | //! | `ACVP-AES-CFB8` | `crypto/modes/tests/acvp_cfb8_tests.rs` | //! | `ACVP-AES-OFB` | nothing yet (OFB is unimplemented) | //! | `ACVP-AES-CTR` | `crypto/modes/tests/acvp_ctr_tests.rs` | +//! | `ACVP-AES-GCM` / `-GMAC` | nothing yet (GCM is unimplemented; it needs GF(2^128) arithmetic) | //! | `ACVP-AES-KW` / `-KWP` | nothing yet (key wrap is unimplemented) | //! | `ACVP-AES-FF1` / `-FF3-1` | nothing yet (format-preserving encryption is unimplemented) | //! diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs index 82cb977d..879c4787 100644 --- a/crypto/modes/benches/modes_benches.rs +++ b/crypto/modes/benches/modes_benches.rs @@ -41,10 +41,10 @@ use bouncycastle_aes::{AES_128, AES_256}; use bouncycastle_core::errors::SymmetricCipherError; use bouncycastle_core::key_material::{KeyMaterial, KeyType}; use bouncycastle_core::traits::{ - Algorithm, BlockCipherDecryptor, BlockCipherEncryptor, ElectronicCodeBook, SecurityStrength, - StreamCipherDecryptor, StreamCipherEncryptor, + AEADCipherEncryptor, Algorithm, BlockCipherDecryptor, BlockCipherEncryptor, ElectronicCodeBook, + SecurityStrength, StreamCipherDecryptor, StreamCipherEncryptor, }; -use bouncycastle_modes::{Cbc, Cfb, Cfb8, Ctr, Decrypting, Ecb, Encrypting}; +use bouncycastle_modes::{Cbc, Ccm, CcmEncryptor, Cfb, Cfb8, Ctr, Decrypting, Ecb, Encrypting}; use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; use std::hint::black_box; @@ -58,6 +58,22 @@ type Aes256Cbc = Cbc; type Aes128Cfb = Cfb; type Aes256Cfb = Cfb; type Aes128Cfb8 = Cfb8; + +/// CCM at the parameters the ACVP vectors and most protocols use: a 12-byte nonce and a full +/// 16-byte tag. The direction is in the type as for the other modes, but the two directions are +/// separate aliases here rather than one generic over `Dir`, because CCM's one-shots live on the +/// direction-specific impl blocks. +const CCM_NONCE_LEN: usize = 12; +const CCM_TAG_LEN: usize = 16; +type Aes128CcmEnc = Ccm; +type Aes128CcmDec = Ccm; + +/// The buffering trait adapter needs a compile-time maximum message size. 4 KiB, not the 16 KiB +/// the other groups use, because it is a stack buffer and the trait puts a second one of the same +/// size on the stack at every one-shot call. +const CCM_BUFFER_LEN: usize = 4096; +type Aes128CcmEncryptor = + CcmEncryptor; type Aes128Ctr = Ctr; type Aes256Ctr = Ctr; type Aes128Ecb = Ecb; @@ -740,8 +756,197 @@ fn bench_init(c: &mut Criterion) { group.finish(); } +/// CCM (SP 800-38C), which is the only authenticated mode here and the only one that costs +/// **two** cipher calls per block. +/// +/// Sec 5.2 builds CCM out of CTR for confidentiality and CBC-MAC for authenticity, over the same +/// key, so every payload block goes through the forward cipher twice: once as a counter block and +/// once as a CBC-MAC input. The number to watch is CCM against the CTR group on the same data, and +/// **which** CTR number matters: +/// +/// * against `modes::ctr::AES_128/16KiB encrypt -- N=1`, CTR's unbatched single-block path, CCM +/// should be **about half** -- two cipher calls per block instead of one, and nothing else; +/// * against CTR's `N=8` batched path, CCM should be about **a quarter**, because CCM cannot batch +/// at all and CTR's pair path roughly doubles it. +/// +/// Measured on the reference machine: 26 MiB/s for CCM against 51 MiB/s for CTR `N=1` and +/// 102 MiB/s for CTR `N=8`, i.e. both ratios as predicted. Materially worse than half of `N=1` +/// would mean something other than the two unavoidable cipher calls is dominating. +/// +/// Neither half of CCM can be batched, and that is inherent, not an omission. The CBC-MAC is serial +/// by construction (Sec 6.1 step 3: `Yi` is the cipher of `Bi XOR Yi-1`), so unlike `Ctr` and the +/// decrypt direction of `Cbc`/`Cfb` there is no pair or four path to take, and the counter blocks +/// are generated one at a time to stay interleaved with it. So CCM is deliberately absent from the +/// batch-path comparison the other groups are about. +/// +/// Encryption and decryption should be within noise of each other: Sec 6.1 and Sec 6.2 do the same +/// work in the opposite order (MAC-then-XOR versus XOR-then-MAC), and only the forward cipher is +/// ever used, so the inverse cipher's cost never enters. +/// +/// The AAD is measured separately, and is the cheap half: it is absorbed into the CBC-MAC only, +/// one cipher call per block rather than two, so AAD-only throughput should be about twice the +/// payload's and about the same as CTR's. +fn bench_ccm_aes128(c: &mut Criterion) { + let key = key::<16>(); + let nonce = [0x24u8; CCM_NONCE_LEN]; + let data = [0xA5u8; DATA_LEN]; + let no_aad: [u8; 0] = []; + + let mut group = c.benchmark_group("modes::ccm::AES_128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("encrypt 16KiB, no AAD", |b| { + b.iter_batched_ref( + || [0u8; DATA_LEN], + |out| { + black_box( + Aes128CcmEnc::encrypt_detached( + black_box(&key), + &nonce, + &no_aad, + black_box(&data), + out, + ) + .unwrap(), + ) + }, + BatchSize::LargeInput, + ) + }); + + // Encrypt once outside the loop so decryption measures a ciphertext that authenticates: a + // failing tag check would short-circuit the comparison and measure the wrong thing. + let mut ciphertext = [0u8; DATA_LEN]; + let (_, tag) = + Aes128CcmEnc::encrypt_detached(&key, &nonce, &no_aad, &data, &mut ciphertext).unwrap(); + + group.bench_function("decrypt 16KiB, no AAD", |b| { + b.iter_batched_ref( + || [0u8; DATA_LEN], + |out| { + black_box( + Aes128CcmDec::decrypt_detached( + black_box(&key), + &nonce, + &no_aad, + black_box(&ciphertext), + &tag, + out, + ) + .unwrap(), + ) + }, + BatchSize::LargeInput, + ) + }); + + // The same payload with 16 KiB of AAD alongside it. The difference from the no-AAD case is one + // cipher call per AAD block, so this should cost about 1.5x the no-AAD case for 2x the bytes. + group.bench_function("encrypt 16KiB with 16KiB AAD", |b| { + b.iter_batched_ref( + || [0u8; DATA_LEN], + |out| { + black_box( + Aes128CcmEnc::encrypt_detached( + black_box(&key), + &nonce, + black_box(&data), + black_box(&data), + out, + ) + .unwrap(), + ) + }, + BatchSize::LargeInput, + ) + }); + + // AAD only: CCM as a pure authentication mode, which Sec 5.3's footnote calls out as the + // empty-payload degenerate case. One cipher call per block, so this is the CTR-comparable half. + group.bench_function("authenticate 16KiB AAD, empty payload", |b| { + b.iter(|| { + let mut out: [u8; 0] = []; + black_box( + Aes128CcmEnc::encrypt_detached( + black_box(&key), + &nonce, + black_box(&data), + &no_aad, + &mut out, + ) + .unwrap(), + ) + }) + }); + + group.finish(); +} + +/// The buffering [`AEADCipherEncryptor`] path against the direct one, on a message that fits the +/// buffer. +/// +/// The two do identical cipher work -- the trait path ends in the same `Ccm` -- so the gap is +/// purely the two extra copies `BUFFER_LEN` forces: the caller's plaintext into the encryptor's +/// buffer, and the finalization buffer into the caller's output. +/// +/// Measured on the reference machine, that gap is **within noise** (25.5 against 25.7 MiB/s): two +/// `memcpy`s of 4 KiB are nothing beside 512 AES calls. So the reason to prefer `Ccm` directly is +/// the `2 * BUFFER_LEN` of memory and the compile-time message cap, not speed. If this ratio ever +/// moves far from 1, the buffering path has started doing real work it should not be. +fn bench_ccm_buffering_pair(c: &mut Criterion) { + let key = key::<16>(); + let data = [0xA5u8; CCM_BUFFER_LEN]; + let no_aad: [u8; 0] = []; + + let mut group = c.benchmark_group("modes::ccm::buffering"); + group.throughput(Throughput::Bytes(CCM_BUFFER_LEN as u64)); + + group.bench_function("AEADCipherEncryptor::encrypt_out 4KiB", |b| { + b.iter_batched_ref( + || [0u8; CCM_BUFFER_LEN], + |out| { + black_box( + Aes128CcmEncryptor::encrypt_out( + black_box(&key), + &no_aad, + black_box(&data), + out, + ) + .unwrap(), + ) + }, + BatchSize::LargeInput, + ) + }); + + // The same 4 KiB through `Ccm` directly, for the ratio. This one also draws no nonce, since + // `Ccm` takes it from the caller, so `bench_ccm_init` covers that difference separately. + let nonce = [0x24u8; CCM_NONCE_LEN]; + group.bench_function("Ccm::encrypt_detached 4KiB", |b| { + b.iter_batched_ref( + || [0u8; CCM_BUFFER_LEN], + |out| { + black_box( + Aes128CcmEnc::encrypt_detached( + black_box(&key), + &nonce, + &no_aad, + black_box(&data), + out, + ) + .unwrap(), + ) + }, + BatchSize::LargeInput, + ) + }); + + group.finish(); +} + criterion_group!( benches, bench_aes128, bench_aes256, bench_cfb_aes128, bench_cfb_aes256, bench_cfb8_aes128, - bench_ctr_aes128, bench_ctr_aes256, bench_ecb_aes128, bench_init + bench_ctr_aes128, bench_ctr_aes256, bench_ecb_aes128, bench_ccm_aes128, + bench_ccm_buffering_pair, bench_init ); criterion_main!(benches); diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs new file mode 100644 index 00000000..fea57345 --- /dev/null +++ b/crypto/modes/src/ccm.rs @@ -0,0 +1,1495 @@ +//! The CCM mode of operation: Counter with Cipher Block Chaining-Message Authentication Code +//! (NIST SP 800-38C, May 2004, errata update 07-20-2007). +//! +//! CCM is the one mode in this crate that is *authenticated*: it produces a tag as well as a +//! ciphertext, and decryption either returns the plaintext or refuses. It is built from two +//! mechanisms this crate already has, under a single key (Sec 5.2: "The same key, K, is used for +//! both the CTR and CBC-MAC mechanisms within CCM"): +//! +//! * **CTR** for confidentiality, over the counter blocks of Appendix A.3; +//! * **CBC-MAC** for authenticity, over the formatted blocks of Appendix A.2. +//! +//! Only the forward cipher function is ever used, in both directions (Sec 3: "Only the forward +//! cipher function of the block cipher algorithm is used within these primitives"), so a +//! permutation that implements nothing but `encrypt_block` works here. +//! +//! # The specification +//! +//! Sec 6.1, the generation-encryption process, quoted verbatim: +//! +//! ```text +//! 1. Apply the formatting function to (N, A, P) to produce the blocks B0, B1, ..., Br. +//! 2. Set Y0 = CIPH_K(B0). +//! 3. For i = 1 to r, do Yi = CIPH_K(Bi XOR Yi-1). +//! 4. Set T = MSB_Tlen(Yr). +//! 5. Apply the counter generation function to generate the counter blocks Ctr0, Ctr1, +//! ..., Ctrm, where m = ceil(Plen/128). +//! 6. For j = 0 to m, do Sj = CIPH_K(Ctrj). +//! 7. Set S = S1 || S2 || ... || Sm. +//! 8. Return C = (P XOR MSB_Plen(S)) || (T XOR MSB_Tlen(S0)). +//! ``` +//! +//! Sec 6.2, the decryption-verification process, quoted verbatim: +//! +//! ```text +//! 1. If Clen <= Tlen, then return INVALID. +//! 2. Apply the counter generation function to generate the counter blocks Ctr0, Ctr1, +//! ..., Ctrm, where m = ceil((Clen - Tlen)/128). +//! 3. For j = 0 to m, do Sj = CIPH_K(Ctrj). +//! 4. Set S = S1 || S2 || ... || Sm. +//! 5. Set P = MSB_Clen-Tlen(C) XOR MSB_Clen-Tlen(S). +//! 6. Set T = LSB_Tlen(C) XOR MSB_Tlen(S0). +//! 7. If N, A, or P is not valid, as discussed in Section 5.4, then return INVALID, else +//! apply the formatting function to (N, A, P) to produce the blocks B0, B1, ..., Br. +//! 8. Set Y0 = CIPH_K(B0). +//! 9. For i = 1 to r, do Yj = CIPH_K(Bi XOR Yi-1). +//! 10. If T != MSB_Tlen(Yr), then return INVALID, else return P. +//! ``` +//! +//! Note step 8's `T XOR MSB_Tlen(S0)`: the tag CCM transmits is the CBC-MAC value **encrypted** +//! under the counter block `Ctr0`, which is reserved for exactly that and never used for payload +//! keystream -- step 7 starts the payload at `S1`. +//! +//! ## Where the ciphertext ends and the tag begins +//! +//! Step 8 returns a single string, `ciphertext || tag`. This type offers both layouts: the inherent +//! [`Ccm::encrypt`] / [`Ccm::decrypt`] produce and consume the spec's own inline string, and the +//! detached pair [`Ccm::encrypt_detached`] / [`Ccm::decrypt_detached`] keeps the tag separate, +//! which is the shape [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] use. +//! +//! # Formatting: the parameters are the const generics +//! +//! Appendix A gives "an example of a formatting function and counter generation function"; Sec 5.4 +//! permits others, but A's is the one every deployment of CCM uses -- it is what makes this +//! "essentially equivalent to the specification of CCM in the draft amendment to the IEEE Standard +//! 802.11" (Appendix A) -- and it is the only one implemented here. Its length conditions (A.1), +//! quoted verbatim: +//! +//! ```text +//! * t is an element of {4, 6, 8, 10, 12, 14, 16}; +//! * q is an element of {2, 3, 4, 5, 6, 7, 8}; +//! * n is an element of {7, 8, 9, 10, 11, 12, 13} +//! * n+q=15; +//! * a<2^64. +//! ``` +//! +//! `t` is `TAG_LEN` and `n` is `NONCE_LEN`, so **`q` is not a parameter**: `n + q = 15` fixes it at +//! `15 - NONCE_LEN`, and A.1 says as much ("a choice for q determines the value of n, namely, +//! n=15-q"). All four of the first conditions are therefore properties of the const parameters and +//! are `const` assertions in the constructor: a `NONCE_LEN` or `TAG_LEN` A.1 does not permit is a +//! **compile** error at the call site, not a runtime `Err`. The fifth, `a < 2^64`, cannot be +//! violated by a `&[u8]` whose length is a `usize`, so there is nothing to check. +//! +//! ## `q` trades nonce space against payload size +//! +//! Because `n + q = 15`, a longer nonce means a shorter length field, and `q` bounds the payload: +//! A.1's "by definition, p<2^8q". A.1 calls this "a tradeoff between the maximum number of +//! invocations of CCM under a given key and the maximum payload length for those invocations": +//! +//! | `NONCE_LEN` (n) | q | max payload | +//! |---|---|---| +//! | 7 | 8 | 2^64 - 1 bytes (no bound in practice) | +//! | 11 | 4 | 4 GiB - 1 | +//! | 12 | 3 | 16 MiB - 1 | +//! | 13 | 2 | 64 KiB - 1 | +//! +//! A payload past that limit is refused with [`SymmetricCipherError::GenericError`]: both the +//! counter and the length field `Q` would overflow, and `Q` is what the MAC commits to. +//! +//! # CCM is not a streaming mode, and what this crate does about it +//! +//! Sec 3 is explicit: +//! +//! > CCM is intended for use in a packet environment, i.e., when all of the data is available in +//! > storage before CCM is applied; CCM is not designed to support partial processing or stream +//! > processing. +//! +//! The reason is `B0`. Appendix A.2.1 puts `Q`, the payload's octet length, *inside the first block +//! the CBC-MAC absorbs*, so nothing at all can be authenticated until the total payload length is +//! known. [`Ctr`](crate::Ctr) and [`Cfb`](crate::Cfb) can hash as they go; CCM structurally cannot. +//! +//! There are exactly two honest ways to live with that, and this module provides both: +//! +//! 1. **Declare the length up front.** [`Ccm::new`] takes the whole AAD and the payload length, so +//! `B0` is formed at construction and everything after it streams with **no buffering at all**: +//! each byte is MACed and XORed as it arrives, and the payload may be any length up to the `q` +//! limit. This is the efficient path and the one the one-shots use. +//! 2. **Buffer.** [`CcmEncryptor`] / [`CcmDecryptor`] implement [`AEADCipherEncryptor`] / +//! [`AEADCipherDecryptor`], whose `do_encrypt_init` is handed a key and nothing else, so they +//! have no length from which to form `B0`. They accumulate the message in a fixed +//! `BUFFER_LEN`-byte array and do all the work at finalization. That is a real cost -- see +//! those types' docs -- and it is the price of the generic AEAD API, not of CCM. +//! +//! A caller who reaches for CCM at all is in Sec 3's packet environment and knows the length, so +//! (1) is the one to use; (2) exists so that CCM composes with code written against the trait. +//! +//! # Security considerations +//! +//! **The nonce must never repeat under one key.** Sec 5.3: "any two distinct data pairs to be +//! protected by CCM during the lifetime of the key shall be assigned distinct nonces". A repeat is +//! worse here than in an unauthenticated mode: it reuses the CTR keystream, and Appendix B.1's +//! footnote describes the resulting forgery -- an attacker who can "induce the +//! decryption-verification process to reuse the nonce" can flip any chosen bit of the payload. The +//! nonce is *not* required to be random ("The nonce is not required to be random"), only unique, so +//! a counter is a valid and often better choice; every deterministic entry point here takes the +//! nonce from the caller, and the entry points that generate one draw it from the library's DRBG. +//! +//! **`TAG_LEN` is a security parameter.** Sec B.2: "a value of Tlen that is less than 64 shall not +//! be used without a careful analysis of the risks of accepting inauthentic data as authentic", and +//! it gives the bound `Tlen >= lg(MaxErrs / Risk)`. A `TAG_LEN` of 4 or 6 is permitted by A.1 and +//! accepted here, because protocols and the ACVP vectors use short tags; prefer 16. +//! +//! **The key is for CCM only.** Sec 5.1: "The key shall be kept secret and shall only be used for +//! the CCM mode", and "The total number of invocations of the block cipher algorithm during the +//! lifetime of the key shall be limited to 2^61". +//! +//! **A failed tag check reveals nothing.** Sec 6.2: "the payload P and the MAC T shall not be +//! revealed", and an unauthorized party must not be able to distinguish a step 7 failure from a +//! step 10 failure, "for example, from the timing of the error message". Step 7 cannot fail here -- +//! the const parameters and the declared length make `N`, `A` and `P` valid by construction -- so +//! there is only one failure path, the constant-time comparison in [`Ccm::do_decrypt_final`]. The +//! one-shots zeroize the plaintext buffer before returning the error. The streaming API cannot; see +//! [`AEADCipherDecryptor`]'s own warning that what `do_update_out` released is not authenticated +//! until the final call returns `Ok`. + +use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, ElectronicCodeBook, RNG, SecurityStrength, +}; +use bouncycastle_rng::HashDRBG_SHA512; +use bouncycastle_utils::ct::ct_eq_bytes; +use bouncycastle_utils::secret::Secret; +use core::marker::PhantomData; + +use crate::{Decrypting, Encrypting}; + +/// CCM (SP 800-38C) over any [`ElectronicCodeBook`] with a 128-bit block. +/// +/// `NONCE_LEN` is the spec's `n` and `TAG_LEN` its `t`; `q`, the width of the length field, is +/// `15 - NONCE_LEN`, because A.1 requires `n + q = 15`. See the module docs for the permitted +/// values -- all checked at compile time -- and for the payload limit `q` implies. +/// +/// `Dir` is [`Encrypting`] or [`Decrypting`], exactly as for the other modes in this crate: +/// `Ccm` has Sec 6.1's methods and nothing else, and `Ccm` +/// has Sec 6.2's. Using the wrong direction is a compile error rather than a runtime one, and there +/// is no state to police: pointing a decryptor at a plaintext is not a mistake this type can be +/// asked to make. +/// +/// [`CcmEncryptor`] and [`CcmDecryptor`] wrap these for the generic +/// [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] traits, at the cost of buffering; see the +/// module docs. +/// +/// Asking an encryptor to verify a tag does not compile -- `do_decrypt_final` exists only on +/// `Ccm`: +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Ccm, Encrypting}; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let ccm = Ccm::::new(&key, &[0u8; 12], &[], 0).unwrap(); +/// ccm.do_decrypt_final(&[0u8; 16]).unwrap(); +/// ``` +/// +/// And nor does the reverse -- a decryptor has no `do_encrypt_final`, so it cannot be tricked into +/// producing a tag over data it never encrypted: +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Ccm, Decrypting}; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// let ccm = Ccm::::new(&key, &[0u8; 12], &[], 0).unwrap(); +/// let _tag = ccm.do_encrypt_final().unwrap(); +/// ``` +/// +/// A nonce length A.1 does not permit does not compile: +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Ccm, Encrypting}; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// // n = 6 is not in {7, ..., 13}: it would make q = 9, which A.1 does not allow. +/// let _ = Ccm::::new(&key, &[0u8; 6], &[], 0); +/// ``` +/// +/// Nor does an odd tag length: +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_modes::{Ccm, Encrypting}; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// // t = 15 is not in {4, 6, 8, 10, 12, 14, 16}. +/// let _ = Ccm::::new(&key, &[0u8; 12], &[], 0); +/// ``` +pub struct Ccm< + P, + Dir, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, +> where + P: ElectronicCodeBook, +{ + perm: P, + // The CBC-MAC chaining value: `Y0` once the constructor has absorbed `B0` (Sec 6.1 step 2), + // then `Yi` as further blocks arrive (step 3). Bytes are XORed into it in place, so part-way + // through a block it holds `Yi-1 XOR (the part of Bi seen so far)`. + y: [u8; BLOCK_LEN], + // How many bytes of the current CBC-MAC input block have been XORed into `y`. + mac_pos: usize, + // `Ctr_i` with its counter field zeroed (A.3, Table 3): the flags octet and the nonce, which + // are the same in every counter block. Public data -- flags and nonce travel in the clear -- + // so deliberately not a `Secret`. + ctr_template: [u8; BLOCK_LEN], + // The current keystream block `Sj` and how much of it has been consumed. Live keystream for + // the payload bytes still to come, so it is zeroized on drop for the same reason `Ctr`'s is. + ks: Secret<[u8; BLOCK_LEN]>, + ks_pos: usize, + // The index `j` of the next keystream block. Starts at 1: step 7 sets `S = S1 || ... || Sm`, + // and `S0` is reserved for the tag. + next_ctr: u64, + // How much of the payload length declared to `new` has not yet been supplied. That length is + // committed to inside `B0`, so supplying a different amount would authenticate a message no + // verifier could reproduce; both directions refuse instead of doing it. + owed: usize, + // Which of the two Sec 6 processes this value runs. Zero-sized: the direction costs no memory. + _dir: PhantomData, +} + +impl< + P, + Dir, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, +> Ccm +where + P: ElectronicCodeBook, +{ + /// The spec's `q`: the octet length of the payload-length field `Q`. A.1 requires `n + q = 15`. + const Q_LEN: usize = 15 - NONCE_LEN; + + /// The largest payload this parameterization can carry, from A.1's "by definition, p<2^8q". + /// + /// `q = 8` would make `2^8q` exactly `2^64`, which does not fit a `u64`; there the bound is + /// `p <= 2^64 - 1`, i.e. `u64::MAX`, which is no bound at all on a `usize` length. + const MAX_PAYLOAD_LEN: u64 = + if Self::Q_LEN >= 8 { u64::MAX } else { (1u64 << (8 * Self::Q_LEN)) - 1 }; + + /// The compile-time shape check, from Appendix A.1 and Sec 5.1; run from the constructor. + /// + /// Every one of these is a property of the const parameters alone, so each is a compile error + /// at the call site. `q` is not checked separately: `NONCE_LEN` in `7..=13` with `q = 15 - n` + /// gives exactly A.1's `q` in `2..=8`. + #[inline] + fn check_shape() { + const { + // Sec 5.1: "For CCM, the block size of the block cipher algorithm shall be 128 bits". + assert!( + BLOCK_LEN == 16, + "CCM requires a 128-bit block cipher (SP 800-38C Sec 5.1): BLOCK_LEN must be 16" + ); + // A.1: "n is an element of {7, 8, 9, 10, 11, 12, 13}". + assert!( + NONCE_LEN >= 7 && NONCE_LEN <= 13, + "CCM nonce length must be 7..=13 bytes (SP 800-38C A.1)" + ); + // A.1: "t is an element of {4, 6, 8, 10, 12, 14, 16}", i.e. even and in 4..=16. Sec 5.4 + // gives the same lower bound from the other side: "No value of Tlen smaller than 32 + // shall be valid". + assert!( + TAG_LEN >= 4 && TAG_LEN <= 16 && TAG_LEN % 2 == 0, + "CCM tag length must be one of 4, 6, 8, 10, 12, 14, 16 bytes (SP 800-38C A.1)" + ); + }; + } + + /// Validates a [`KeyMaterial`] and expands it into the permutation's key schedule. + /// + /// The strength check is [`ElectronicCodeBook::new`]'s; this adds the [`KeyType`] check that + /// the trait leaves to the mode. + fn checked_perm(key: &KeyMaterial) -> Result { + if key.key_type() != KeyType::SymmetricCipherKey { + return Err( + KeyMaterialError::InvalidKeyType("CCM requires a SymmetricCipherKey").into() + ); + } + P::new(key) + } + + /// Draws a nonce from `rng`, for [`CcmEncryptor`]'s constructors. + /// + /// Sec 5.3 requires uniqueness, not randomness, but a CSPRNG draw is the only way to be unique + /// without state the trait's `do_encrypt_init` does not have. Every entry point that takes the + /// nonce from the caller instead is the better one where the caller can guarantee uniqueness + /// itself; see the module's security considerations. + fn nonce_from_rng(rng: &mut dyn RNG) -> Result<[u8; NONCE_LEN], SymmetricCipherError> { + let mut nonce = [0u8; NONCE_LEN]; + rng.next_bytes_out(&mut nonce)?; + Ok(nonce) + } + + /// Begins a CCM flow: formats `B0`, absorbs it and all of `A` into the CBC-MAC, and readies the + /// counter blocks. Everything after this streams without buffering. + /// + /// The whole AAD is taken here, and `payload_len` declared here, because Appendix A.2.1 puts the + /// payload length inside `B0` and A.2.2 puts the AAD length in front of the AAD: neither can be + /// encoded incrementally. See the module docs. + /// + /// * `key` must be a [`KeyType::SymmetricCipherKey`] of at least the permutation's strength. + /// * `nonce` **must not** repeat under `key`; see the module's security considerations. + /// * `aad` is authenticated but not encrypted, and may be empty. + /// * `payload_len` is the exact number of payload bytes that will follow. Supplying any other + /// amount is refused, at the update or at finalization. + /// + /// # Errors + /// [`SymmetricCipherError::KeyMaterialError`] for a key of the wrong type or strength, and + /// [`SymmetricCipherError::GenericError`] if `payload_len` exceeds A.1's `2^8q - 1`; see + /// [`Ccm`] for the table. + pub fn new( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + payload_len: usize, + ) -> Result { + // The shape check and the payload-limit check both belong to `from_perm`, which is the one + // path every construction goes through; duplicating them here would be two more `Err` + // sites that could drift apart from it. + let perm = Self::checked_perm(key)?; + Self::from_perm(perm, nonce, aad, payload_len) + } + + /// As [`Self::new`], from a key schedule that has already been expanded and a payload length + /// that has already been checked against [`Self::MAX_PAYLOAD_LEN`]. + /// + /// This is what [`CcmEncryptor`] / [`CcmDecryptor`] call at finalization: they expand the key + /// once in their own constructor, long before they know the payload length, and hand the + /// schedule over here rather than storing the [`KeyMaterial`] and re-expanding it. + fn from_perm( + perm: P, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + payload_len: usize, + ) -> Result { + Self::check_shape(); + if payload_len as u64 > Self::MAX_PAYLOAD_LEN { + return Err(SymmetricCipherError::GenericError( + "CCM payload longer than 2^8q - 1, the limit the nonce length implies (A.1)", + )); + } + + // A.3, Tables 3 and 4: `Ctr_i` is `Flags || N || [i]_8q`, and its flags octet has both + // reserved bits and bits 3, 4 and 5 zero -- "to ensure that all the counter blocks are + // distinct from B0", whose bits 3..5 encode `t` and so cannot all be zero -- leaving bits + // 0..2 to hold "the same encoding of q as in B0". + let mut ctr_template = [0u8; BLOCK_LEN]; + ctr_template[0] = (Self::Q_LEN - 1) as u8; + ctr_template[1..1 + NONCE_LEN].copy_from_slice(nonce); + + let mut ccm = Self { + perm, + // Sec 6.1 step 2 is `Y0 = CIPH_K(B0)`, with no XOR, unlike step 3's `Bi XOR Yi-1`. + // Starting the chaining value at zero unifies the two: `B0 XOR 0 = B0`, so absorbing + // `B0` through the same path as every other block yields exactly `Y0`. + y: [0u8; BLOCK_LEN], + mac_pos: 0, + ctr_template, + ks: Secret::new(), + // Nothing buffered; the first payload byte forces a refill. + ks_pos: BLOCK_LEN, + next_ctr: 1, + owed: payload_len, + _dir: PhantomData, + }; + + ccm.mac_absorb(&Self::format_b0(nonce, !aad.is_empty(), payload_len as u64)); + + // A.2.2: if `a > 0`, "the encoding of a is concatenated with the associated data A, + // followed by the minimum number of '0' bits, possibly none, such that the resulting string + // can be partitioned into 16-octet blocks". If `a = 0` there are no AAD blocks at all, so + // nothing is absorbed and nothing is padded. + if !aad.is_empty() { + let (encoded, encoded_len) = Self::encode_aad_len(aad.len() as u64); + ccm.mac_absorb(&encoded[..encoded_len]); + ccm.mac_absorb(aad); + // The AAD's own blocks `B1 ... Bu` end on a block boundary, and A.2.3's payload blocks + // are `Bu+1 ...`. So the zero pad happens *here*, not once at the very end. + ccm.mac_pad(); + } + + Ok(ccm) + } + + /// The encoding of `a`, the AAD's octet length, which A.2.2 places in front of the AAD. + /// + /// Returns the bytes and how many of them are used; the buffer is sized for the longest case. + /// A.2.2 gives three, quoted verbatim: + /// + /// ```text + /// * If 0 < a < 2^16-2^8, then a is encoded as [a]_16, i.e., two octets. + /// * If 2^16-2^8 <= a < 2^32, then a is encoded as 0xff || 0xfe || [a]_32, i.e., six octets. + /// * If 2^32 <= a < 2^64, then a is encoded as 0xff || 0xff || [a]_64, i.e., ten octets. + /// ``` + /// + /// The first boundary is `2^16 - 2^8` (65280), **not** `2^16`: A.2.2 reserves the encodings + /// whose first octet is `0xff` so that the three cases can be told apart, and `[a]_16` for + /// `a >= 65280` would collide with them ("in the first case, the first octet will not be 0xff + /// as it will for the second and third cases"). Getting that bound wrong is the kind of error + /// that only shows up on a 64 KiB AAD, which is why this is a separate function with its own + /// tests rather than three inline branches: the third case's `2^32` boundary is not reachable + /// through the public API at all without a 4 GiB allocation, but it is trivially reachable here. + /// + /// `a` is a `usize` at every call site, so A.1's `a < 2^64` holds for free and there is nothing + /// to reject; the third case is reachable in practice only on a target with a >32-bit `usize`. + #[inline] + fn encode_aad_len(a: u64) -> ([u8; 10], usize) { + let mut out = [0u8; 10]; + if a < (1 << 16) - (1 << 8) { + out[..2].copy_from_slice(&(a as u16).to_be_bytes()); + (out, 2) + } else if a < (1u64 << 32) { + out[0] = 0xff; + out[1] = 0xfe; + out[2..6].copy_from_slice(&(a as u32).to_be_bytes()); + (out, 6) + } else { + out[0] = 0xff; + out[1] = 0xff; + out[2..10].copy_from_slice(&a.to_be_bytes()); + (out, 10) + } + } + + /// `B0`, the first block of the formatted input (A.2.1). + /// + /// Table 1 gives the flags octet: + /// + /// ```text + /// Bit number 7 6 5 4 3 2 1 0 + /// Contents Reserved Adata [(t-2)/2]_3 [q-1]_3 + /// ``` + /// + /// with the Reserved bit "reserved to enable future extensions of the formatting; it shall be + /// set to '0'", and A.2.2's rule for the other flag: "The Adata bit is '0' if a=0 and '1' if + /// a>0", which is what `has_aad` carries. Table 2 gives the rest: + /// + /// ```text + /// Octet number 0 1 ... 15-q 16-q ... 15 + /// Contents Flags N Q + /// ``` + /// + /// Neither three-bit field can be zero -- A.1 notes "the encoding 000 in both cases does not + /// correspond to a permitted value of t or q" -- which is what [`Self::check_shape`] enforces + /// and what keeps `B0` distinct from every counter block (A.3). + #[inline] + fn format_b0(nonce: &[u8; NONCE_LEN], has_aad: bool, payload_len: u64) -> [u8; BLOCK_LEN] { + let mut b0 = [0u8; BLOCK_LEN]; + // The three fields occupy disjoint bit ranges -- bit 6, bits 5-3, bits 2-0 -- and + // `check_shape` bounds the two encoded values so neither can overflow its field. So these + // `|`s are exactly equivalent to `^`, and `cargo mutants` reports that substitution as a + // surviving mutant; it is one of the OR/XOR equivalences CLAUDE.md calls acceptable, not a + // gap in the tests. `|` is written because these are field assignments, not a combination. + b0[0] = (u8::from(has_aad) << 6) + | ((((TAG_LEN - 2) / 2) as u8) << 3) + | ((Self::Q_LEN - 1) as u8); + b0[1..1 + NONCE_LEN].copy_from_slice(nonce); + Self::put_q_field(&mut b0, payload_len); + b0 + } + + /// Writes `[x]_8q` into the trailing `Q_LEN` octets of `block`: the `Q` field of `B0` (A.2.1, + /// Table 2) and the counter field of `Ctr_i` (A.3, Table 3), which occupy the same octets. + /// + /// `Q_LEN <= 8`, so the low `Q_LEN` bytes of a big-endian `u64` are exactly `[x]_8q`. Nothing + /// is ever truncated in a way that matters: [`Self::new`] refuses a payload above + /// [`Self::MAX_PAYLOAD_LEN`], and the counter cannot pass that either, since there is one + /// counter block per `BLOCK_LEN` payload bytes. + #[inline] + fn put_q_field(block: &mut [u8; BLOCK_LEN], x: u64) { + let be = x.to_be_bytes(); + block[BLOCK_LEN - Self::Q_LEN..].copy_from_slice(&be[8 - Self::Q_LEN..]); + } + + /// Absorbs `data` into the CBC-MAC as the next bytes of the formatted block string. + /// + /// Implements Sec 6.1 steps 2 and 3 together, incrementally: bytes are XORed into `y` at + /// `mac_pos`, and each time a whole block has gone in, `CIPH_K` is applied. Since `y` holds + /// `Yi-1` when a block starts, XORing `Bi` in byte by byte and then enciphering is exactly + /// `Yi = CIPH_K(Bi XOR Yi-1)`, whatever chunking `data` arrives in. + #[inline] + fn mac_absorb(&mut self, data: &[u8]) { + let mut rest = data; + while !rest.is_empty() { + let take = core::cmp::min(BLOCK_LEN - self.mac_pos, rest.len()); + let (now, later) = rest.split_at(take); + for (slot, b) in self.y[self.mac_pos..].iter_mut().zip(now) { + *slot ^= *b; + } + self.mac_pos += take; + if self.mac_pos == BLOCK_LEN { + self.perm.encrypt_block(&mut self.y); + self.mac_pos = 0; + } + rest = later; + } + } + + /// Finishes a partly-filled CBC-MAC block by zero-padding it: A.2.2 for the AAD and A.2.3 for + /// the payload, both "concatenated with the minimum number of '0' bits, possibly none". + /// + /// The pad itself is free. [`Self::mac_absorb`] XORs into `y`, and XORing zero changes nothing, + /// so all that is left to do is apply `CIPH_K` to the block already sitting there. "Possibly + /// none" is the `mac_pos == 0` case, where the string already ends on a block boundary and + /// adding a whole block of zeros would be wrong. + #[inline] + fn mac_pad(&mut self) { + if self.mac_pos != 0 { + self.perm.encrypt_block(&mut self.y); + self.mac_pos = 0; + } + } + + /// Generates the next keystream block, `Sj = CIPH_K(Ctrj)` for the current `j` (Sec 6.1 + /// steps 5-6), and advances `j`. + #[inline] + fn refill_keystream(&mut self) { + let mut ctr = self.ctr_template; + Self::put_q_field(&mut ctr, self.next_ctr); + *self.ks = ctr; + self.perm.encrypt_block(&mut self.ks); + self.next_ctr += 1; + self.ks_pos = 0; + } + + /// XORs `data` in place with the next `data.len()` bytes of `S1 || S2 || ...`. + /// + /// This is step 8's `P XOR MSB_Plen(S)` and Sec 6.2 step 5's `MSB(C) XOR MSB(S)` -- the same + /// operation, which is why one function serves both directions. A call may start and end + /// part-way through a keystream block, so the caller's chunking is invisible in the output, and + /// only the tail of the very last block is ever discarded. + #[inline] + fn apply_keystream(&mut self, data: &mut [u8]) { + let mut rest = data; + while !rest.is_empty() { + if self.ks_pos == BLOCK_LEN { + self.refill_keystream(); + } + let take = core::cmp::min(BLOCK_LEN - self.ks_pos, rest.len()); + let (now, later) = rest.split_at_mut(take); + for (b, k) in now.iter_mut().zip(self.ks[self.ks_pos..].iter()) { + *b ^= *k; + } + self.ks_pos += take; + rest = later; + } + } + + /// Debits `len` bytes from the payload length declared to [`Self::new`]. + #[inline] + fn take_owed(&mut self, len: usize) -> Result<(), SymmetricCipherError> { + if len > self.owed { + return Err(SymmetricCipherError::StateError( + "CCM was given more payload than the length declared to `new`, which B0 commits to", + )); + } + self.owed -= len; + Ok(()) + } + + /// Completes the CBC-MAC and returns the transmitted tag: step 4's `T = MSB_Tlen(Yr)`, + /// encrypted as step 8's `T XOR MSB_Tlen(S0)`. + /// + /// `S0 = CIPH_K(Ctr0)` is computed here rather than at construction because `Ctr0` is used + /// exactly once, at the end; the payload keystream starts at `S1` (step 7). + fn finish_mac(mut self) -> [u8; TAG_LEN] { + // A.2.3: the payload's own blocks are zero-padded to a block boundary. + self.mac_pad(); + + let mut s0 = self.ctr_template; + Self::put_q_field(&mut s0, 0); + self.perm.encrypt_block(&mut s0); + + // `MSB_Tlen` of a byte-aligned value is its first `TAG_LEN` bytes; A.1 makes `t` an octet + // count, so `Tlen` is always a multiple of 8 here. + let mut tag = [0u8; TAG_LEN]; + for (t, (y, s)) in tag.iter_mut().zip(self.y.iter().zip(s0.iter())) { + *t = *y ^ *s; + } + tag + } +} + +/// Sec 6.1, the generation-encryption process. Present only on the encrypting direction, so a +/// decryptor cannot be asked to produce a tag. +impl + Ccm +where + P: ElectronicCodeBook, +{ + /// Encrypts `data` in place and authenticates it. + /// + /// Step 8 XORs the *plaintext* with the keystream, and step 1 formats the *plaintext* into the + /// blocks the MAC covers, so the plaintext is absorbed before it is overwritten. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if `data` would take the total past the declared + /// payload length. + pub fn do_encrypt_update(&mut self, data: &mut [u8]) -> Result<(), SymmetricCipherError> { + self.take_owed(data.len())?; + self.mac_absorb(data); + self.apply_keystream(data); + Ok(()) + } + + /// Finishes an encryption and returns the tag (Sec 6.1 steps 4 and 8). + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if less payload was supplied than the length declared + /// to [`Self::new`] -- `B0` commits to that length, so a short message would produce a tag no + /// verifier could reproduce. + pub fn do_encrypt_final(self) -> Result<[u8; TAG_LEN], SymmetricCipherError> { + if self.owed != 0 { + return Err(SymmetricCipherError::StateError( + "CCM was given less payload than the length declared to `new`, which B0 commits to", + )); + } + Ok(self.finish_mac()) + } + + /// One-shot generation-encryption with a **detached** tag (Sec 6.1). + /// + /// Writes `plaintext.len()` bytes of ciphertext into `ciphertext` and returns that count with + /// the tag. For the spec's own inline `ciphertext || tag` string, use [`Self::encrypt`]. + /// + /// # Errors + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `ciphertext` is too short, plus + /// [`Self::new`]'s errors. + pub fn encrypt_detached( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + if ciphertext.len() < plaintext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "ciphertext", + plaintext.len(), + )); + } + let mut ccm = Self::new(key, nonce, aad, plaintext.len())?; + let out = &mut ciphertext[..plaintext.len()]; + out.copy_from_slice(plaintext); + ccm.do_encrypt_update(out)?; + let tag = ccm.do_encrypt_final()?; + Ok((plaintext.len(), tag)) + } + + /// One-shot generation-encryption producing the spec's own output string (Sec 6.1 step 8): + /// `C = (P XOR MSB_Plen(S)) || (T XOR MSB_Tlen(S0))`, i.e. `ciphertext || tag` inline. + /// + /// `ciphertext` needs `plaintext.len() + TAG_LEN` bytes; the return is how many were written. + /// + /// # Errors + /// As [`Self::encrypt_detached`]. + pub fn encrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], + ciphertext: &mut [u8], + ) -> Result { + let needed = plaintext.len() + TAG_LEN; + if ciphertext.len() < needed { + return Err(SymmetricCipherError::IncorrectOutputBufferLength("ciphertext", needed)); + } + let (data, tag_out) = ciphertext[..needed].split_at_mut(plaintext.len()); + let (_, tag) = Self::encrypt_detached(key, nonce, aad, plaintext, data)?; + tag_out.copy_from_slice(&tag); + Ok(needed) + } +} + +/// Sec 6.2, the decryption-verification process. Present only on the decrypting direction, so an +/// encryptor cannot be asked to verify a tag. +impl + Ccm +where + P: ElectronicCodeBook, +{ + /// Decrypts `data` in place and authenticates the recovered plaintext. + /// + /// The mirror of [`Self::do_encrypt_update`] with the two steps swapped: Sec 6.2 recovers `P` in + /// step 5 and only then formats `(N, A, P)` in step 7, so the MAC is fed the plaintext here too, + /// never the ciphertext. + /// + /// The bytes this writes are **not authenticated** until [`Self::do_decrypt_final`] returns + /// `Ok`. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] if `data` would take the total past the declared + /// payload length. + pub fn do_decrypt_update(&mut self, data: &mut [u8]) -> Result<(), SymmetricCipherError> { + self.take_owed(data.len())?; + self.apply_keystream(data); + self.mac_absorb(data); + Ok(()) + } + + /// Finishes a decryption by checking `tag`: Sec 6.2 step 10, "If T != MSB_Tlen(Yr), then return + /// INVALID, else return P". + /// + /// The comparison is [`ct_eq_bytes`], so it does not leak how much of the tag matched. Sec 6.2 + /// also requires that a caller cannot tell step 7's failure from step 10's; step 7 cannot fail + /// here, so there is nothing to distinguish -- see the module's security considerations. + /// + /// # Errors + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify, and + /// [`SymmetricCipherError::StateError`] if less ciphertext was supplied than the length declared + /// to [`Self::new`]. + pub fn do_decrypt_final(self, tag: &[u8; TAG_LEN]) -> Result<(), SymmetricCipherError> { + if self.owed != 0 { + return Err(SymmetricCipherError::StateError( + "CCM was given less ciphertext than the length declared to `new`, which B0 commits to", + )); + } + if ct_eq_bytes(&self.finish_mac(), tag) { + Ok(()) + } else { + Err(SymmetricCipherError::AEADTagCheckFailed) + } + } + + /// One-shot decryption-verification with a **detached** tag (Sec 6.2). + /// + /// On failure `plaintext` is zeroized before the error is returned, so Sec 6.2's "the payload P + /// and the MAC T shall not be revealed" holds even for a caller who ignores the `Result`. + /// + /// # Errors + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify, + /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `plaintext` is too short, plus + /// [`Self::new`]'s errors. + pub fn decrypt_detached( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + tag: &[u8; TAG_LEN], + plaintext: &mut [u8], + ) -> Result { + if plaintext.len() < ciphertext.len() { + return Err(SymmetricCipherError::IncorrectOutputBufferLength( + "plaintext", + ciphertext.len(), + )); + } + let mut ccm = Self::new(key, nonce, aad, ciphertext.len())?; + let out = &mut plaintext[..ciphertext.len()]; + out.copy_from_slice(ciphertext); + ccm.do_decrypt_update(out)?; + match ccm.do_decrypt_final(tag) { + Ok(()) => Ok(ciphertext.len()), + Err(e) => { + // Sec 6.2: on INVALID the payload "shall not be revealed". A plain `fill` because + // this crate 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. + out.fill(0); + Err(e) + } + } + } + + /// One-shot decryption-verification of the spec's own output string (Sec 6.2), splitting the + /// trailing `TAG_LEN` bytes off `ciphertext` as the tag -- step 6's `LSB_Tlen(C)`. + /// + /// # Errors + /// [`SymmetricCipherError::GenericError`] for Sec 6.2 step 1, "If Clen <= Tlen, then return + /// INVALID", which is a malformed input rather than a failed check; otherwise as + /// [`Self::decrypt_detached`]. + pub fn decrypt( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ciphertext: &[u8], + plaintext: &mut [u8], + ) -> Result { + // Sec 6.2 step 1, "If Clen <= Tlen, then return INVALID", and the split of step 6's + // `LSB_Tlen(C)` off the end, in one operation: `split_last_chunk` is `None` exactly when + // the string is too short to contain a tag, and otherwise hands back the tag already typed + // as `&[u8; TAG_LEN]`. Doing it in two steps would leave an arithmetic split followed by an + // array conversion that cannot fail but still has to be handled. + // + // Note the spec's `Clen <= Tlen` is on the *bit* lengths of a string that also carries the + // payload; a `C` of exactly `TAG_LEN` octets is an empty payload plus its tag, which is + // valid -- Sec 5.3's footnote, "The payload may also be empty". So the octet test here + // admits equality, which is what `split_last_chunk` does. + let Some((data, tag)) = ciphertext.split_last_chunk::() else { + return Err(SymmetricCipherError::GenericError( + "CCM ciphertext shorter than the tag (SP 800-38C Sec 6.2 step 1)", + )); + }; + Self::decrypt_detached(key, nonce, aad, data, tag, plaintext) + } +} + +impl< + P, + Dir, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, +> Algorithm for Ccm +where + P: ElectronicCodeBook, +{ + /// The underlying permutation's name. The mode is not appended: `&'static str`s cannot be + /// concatenated in a `const`, and the mode is already in the type. + const ALG_NAME: &'static str = P::ALG_NAME; + /// A mode does not change the strength of the underlying cipher. + const MAX_SECURITY_STRENGTH: SecurityStrength = P::MAX_SECURITY_STRENGTH; +} + +/// Adapts [`Ccm`] to [`AEADCipherEncryptor`] by buffering the whole message. +/// +/// [`AEADCipherEncryptor::do_encrypt_init`] is handed a key and nothing else, but CCM cannot form +/// `B0` -- and so cannot authenticate anything at all -- until it knows the total payload length +/// (Appendix A.2.1; see the module docs). This type therefore accumulates the AAD and the payload +/// in two `BUFFER_LEN`-byte arrays and runs the whole of Sec 6.1 in +/// [`do_encrypt_final`](AEADCipherEncryptor::do_encrypt_final), which is why `FINAL_LEN` is +/// `BUFFER_LEN`: every ciphertext byte is "flushed at finalization", and +/// [`update_out_len`](AEADCipherEncryptor::update_out_len) is identically `0`. +/// +/// A message or an AAD longer than `BUFFER_LEN` is refused with +/// [`SymmetricCipherError::GenericError`]. Pick `BUFFER_LEN` from the largest packet the protocol +/// allows -- CCM is a packet mode (Sec 3), so there is such a number. +/// +/// # Memory +/// +/// `2 * BUFFER_LEN` bytes in the value itself, plus the `FINAL_LEN`-byte buffer the trait's +/// provided one-shots put on the stack: about `3 * BUFFER_LEN` in total through +/// [`encrypt_out`](AEADCipherEncryptor::encrypt_out). The inherent [`Ccm`] API costs one block of +/// each of chaining value, counter template and keystream regardless of message size, so **prefer +/// it** unless you specifically need the trait. +pub struct CcmEncryptor< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> where + P: ElectronicCodeBook, +{ + // The key schedule, expanded once here and handed to `Ccm::from_perm` at finalization, so no + // second copy of the key material is kept. + perm: P, + nonce: [u8; NONCE_LEN], + // Associated data is authenticated but not encrypted, and travels in the clear, so it is not + // secret and is not wrapped. + aad: [u8; BUFFER_LEN], + aad_len: usize, + // The plaintext, held until finalization; wrapped so it is zeroized on drop. + data: Secret<[u8; BUFFER_LEN]>, + data_len: usize, + // Set by the first `do_update_out`, which closes the AAD phase (see `do_update_aad`). + data_started: bool, +} + +impl< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> Algorithm for CcmEncryptor +where + P: ElectronicCodeBook, +{ + const ALG_NAME: &'static str = P::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = P::MAX_SECURITY_STRENGTH; +} + +impl< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> AEADCipherEncryptor + for CcmEncryptor +where + P: ElectronicCodeBook, +{ + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + let mut rng = HashDRBG_SHA512::new_from_os(); + Self::do_encrypt_init_rng(key, &mut rng) + } + + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; NONCE_LEN]), SymmetricCipherError> { + // The shape check belongs here too: this type never calls `Ccm::new`, and without it a + // `NONCE_LEN` or `TAG_LEN` A.1 forbids would not be caught until `do_encrypt_final`. + Ccm::::check_shape(); + let perm = Ccm::::checked_perm(key)?; + let nonce = + Ccm::::nonce_from_rng(rng)?; + Ok(( + Self { + perm, + nonce, + aad: [0u8; BUFFER_LEN], + aad_len: 0, + data: Secret::new(), + data_len: 0, + data_started: false, + }, + nonce, + )) + } + + /// Buffers `aad`. A sequence of calls is equivalent to one call over the concatenation, which + /// is what A.2.2 needs: the AAD is length-prefixed, so it can only be encoded once all of it + /// is in hand. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] for a non-empty `aad` after the first + /// `do_update_out`, and [`SymmetricCipherError::GenericError`] if the total would exceed + /// `BUFFER_LEN`. + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + if aad.is_empty() { + return Ok(()); + } + if self.data_started { + return Err(SymmetricCipherError::StateError("CCM: do_update_aad after do_update_out")); + } + let end = self.aad_len + aad.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError( + "CCM: associated data longer than BUFFER_LEN", + )); + } + self.aad[self.aad_len..end].copy_from_slice(aad); + self.aad_len = end; + Ok(()) + } + + /// Identically `0`: nothing can be released before the payload length is known, so the whole + /// ciphertext comes out of `do_encrypt_final`. + fn update_out_len(&self, _input_len: usize) -> usize { + 0 + } + + /// Buffers `plaintext` and writes nothing, per [`Self::update_out_len`]. `ciphertext` is + /// untouched and may be empty. + /// + /// # Errors + /// [`SymmetricCipherError::GenericError`] if the total would exceed `BUFFER_LEN`. Nothing is + /// consumed in that case. + fn do_update_out( + &mut self, + plaintext: &[u8], + _ciphertext: &mut [u8], + ) -> Result { + // Set before the length check so that a refused oversized call still closes the AAD phase: + // the phase order is about call history, and this call happened. + self.data_started = true; + let end = self.data_len + plaintext.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError("CCM: payload longer than BUFFER_LEN")); + } + self.data[self.data_len..end].copy_from_slice(plaintext); + self.data_len = end; + Ok(0) + } + + /// Runs the whole of Sec 6.1 over the buffered message: writes the ciphertext to `output` and + /// returns its length with the tag. + fn do_encrypt_final( + mut self, + output: &mut [u8; BUFFER_LEN], + ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { + let len = self.data_len; + // Move the schedule out rather than cloning it; `self` is consumed either way. `Secret`'s + // `Default` gives a zeroed placeholder, so nothing sensitive is left behind in `self.perm` + // -- `P` holds its own schedule in a `Secret` that is dropped with the `Ccm` below. + let mut ccm = Ccm::::from_perm( + self.perm, + &self.nonce, + &self.aad[..self.aad_len], + len, + )?; + output[..len].copy_from_slice(&self.data[..len]); + // Scrub the plaintext copy as soon as the ciphertext is in `output`; `self` is dropped at + // the end of this call anyway, but the buffer is large and this keeps the window short. + ccm.do_encrypt_update(&mut output[..len])?; + self.data.zeroize(); + let tag = ccm.do_encrypt_final()?; + Ok((len, tag)) + } +} + +/// Adapts [`Ccm`] to [`AEADCipherDecryptor`] by buffering the whole message; the mirror of +/// [`CcmEncryptor`], and see it for why the buffering is unavoidable and what it costs. +pub struct CcmDecryptor< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> where + P: ElectronicCodeBook, +{ + perm: P, + nonce: [u8; NONCE_LEN], + aad: [u8; BUFFER_LEN], + aad_len: usize, + // Ciphertext rather than plaintext, so not secret in itself; wrapped anyway, because + // `do_decrypt_final` decrypts in place before the tag is checked. + data: Secret<[u8; BUFFER_LEN]>, + data_len: usize, + data_started: bool, +} + +impl< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> Algorithm for CcmDecryptor +where + P: ElectronicCodeBook, +{ + const ALG_NAME: &'static str = P::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = P::MAX_SECURITY_STRENGTH; +} + +impl< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +> AEADCipherDecryptor + for CcmDecryptor +where + P: ElectronicCodeBook, +{ + fn do_decrypt_init( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + ) -> Result { + Ccm::::check_shape(); + let perm = Ccm::::checked_perm(key)?; + Ok(Self { + perm, + nonce: *nonce, + aad: [0u8; BUFFER_LEN], + aad_len: 0, + data: Secret::new(), + data_len: 0, + data_started: false, + }) + } + + /// As [`CcmEncryptor::do_update_aad`](AEADCipherEncryptor::do_update_aad); the concatenation + /// must match the encryptor's byte for byte or the tag check fails. + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + if aad.is_empty() { + return Ok(()); + } + if self.data_started { + return Err(SymmetricCipherError::StateError("CCM: do_update_aad after do_update_out")); + } + let end = self.aad_len + aad.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError( + "CCM: associated data longer than BUFFER_LEN", + )); + } + self.aad[self.aad_len..end].copy_from_slice(aad); + self.aad_len = end; + Ok(()) + } + + /// Identically `0`. This is the one thing a CCM decryptor gets *right* by being forced to + /// buffer: it releases no plaintext at all before the tag has been checked, so + /// [`AEADCipherDecryptor`]'s warning about unauthenticated output cannot bite a caller here. + fn update_out_len(&self, _input_len: usize) -> usize { + 0 + } + + /// Buffers `ciphertext` and writes nothing, per [`Self::update_out_len`]. + /// + /// # Errors + /// [`SymmetricCipherError::GenericError`] if the total would exceed `BUFFER_LEN`. + fn do_update_out( + &mut self, + ciphertext: &[u8], + _plaintext: &mut [u8], + ) -> Result { + self.data_started = true; + let end = self.data_len + ciphertext.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError( + "CCM: ciphertext longer than BUFFER_LEN", + )); + } + self.data[self.data_len..end].copy_from_slice(ciphertext); + self.data_len = end; + Ok(0) + } + + /// Runs the whole of Sec 6.2 over the buffered message. + /// + /// On failure `output` is zeroized before the error is returned: Sec 6.2's "the payload P and + /// the MAC T shall not be revealed". + /// + /// # Errors + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. + fn do_decrypt_final( + mut self, + tag: &[u8; TAG_LEN], + output: &mut [u8; BUFFER_LEN], + ) -> Result { + let len = self.data_len; + let mut ccm = Ccm::::from_perm( + self.perm, + &self.nonce, + &self.aad[..self.aad_len], + len, + )?; + output[..len].copy_from_slice(&self.data[..len]); + ccm.do_decrypt_update(&mut output[..len])?; + self.data.zeroize(); + match ccm.do_decrypt_final(tag) { + Ok(()) => Ok(len), + Err(e) => { + output[..len].fill(0); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + //! Tests for the private formatting helpers, which are what a reviewer with SP 800-38C open + //! most needs to check and which no public API exposes directly. + //! + //! The expected values are the `B` and `Ctr_i` strings printed in the spec's own Appendix C + //! examples, transcribed from the errata-updated PDF. Appendix C gives the formatted block + //! string for each example, so these pin the flags octet, the placement of `N` and `Q`, and + //! the AAD length encoding against the document rather than against this implementation. + + use super::*; + use bouncycastle_core::key_material::KeyType; + + /// A stand-in permutation: the identity. `B0` and `Ctr_i` are formatted *before* any cipher + /// call, so the identity is enough to read them back out of the state, and it keeps these + /// tests about the formatting function rather than about AES. + struct Identity; + + impl Algorithm for Identity { + const ALG_NAME: &'static str = "identity"; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; + } + + impl ElectronicCodeBook<16, 16> for Identity { + fn new(_key: &KeyMaterial<16>) -> Result { + Ok(Identity) + } + fn encrypt_block(&self, _block: &mut [u8; 16]) {} + fn decrypt_block(&self, _block: &mut [u8; 16]) {} + } + + fn key() -> KeyMaterial<16> { + KeyMaterial::<16>::from_bytes_as_type( + &[ + 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, + 0x4e, 0x4f, + ], + KeyType::SymmetricCipherKey, + ) + .expect("Appendix C's 128-bit key") + } + + /// Appendix C.1: `Tlen=32, Nlen=56, Alen=64, Plen=32`, so `t = 4`, `n = 7`, `q = 8`. + /// + /// The spec prints `B` as + /// `4f101112 13141516 00000000 00000004 | 00080001 02030405 06070000 00000000 | ...`, + /// so `B0` is `4f` then the 7-byte nonce then `[4]_64`, and `B1` is `[8]_16` then the 8-byte + /// AAD then six zero bytes of pad. + /// + /// C.1's AAD is 8 bytes, so its Adata bit is set. + #[test] + fn c1_b0_matches_the_spec() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + assert_eq!( + Ccm::::format_b0(&nonce, true, 4), + [0x4f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0, 0, 0, 0, 0, 0, 0, 4], + "C.1 B0: flags 0x4f = Adata 1 | [(4-2)/2]_3 = 001 | [8-1]_3 = 111, then Q = [4]_64" + ); + } + + /// A.2.2: the Adata bit is "'0' if a=0 and '1' if a>0", and it is bit 6 -- so clearing it must + /// take C.1's `0x4f` to `0x0f` and change nothing else in the block. + #[test] + fn adata_flag_is_bit_6_of_the_flags_octet() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let with = Ccm::::format_b0(&nonce, true, 4); + let without = Ccm::::format_b0(&nonce, false, 4); + assert_eq!(without[0], 0x0f, "a = 0 clears bit 6, leaving the t and q fields alone"); + assert_eq!(with[0] ^ without[0], 1 << 6, "Adata is bit 6 and nothing else"); + assert_eq!(with[1..], without[1..], "the flag must not disturb N or Q"); + } + + /// The constructor really does absorb the `B0` that [`Ccm::format_b0`] built. With the identity + /// permutation the CBC-MAC chaining value after one block is that block itself, so a + /// no-AAD, no-payload construction leaves `B0` sitting in `y`. + /// + /// Without this, `format_b0` could be correct and unused. + #[test] + fn the_constructor_absorbs_b0() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let ccm = Ccm::::new(&key(), &nonce, &[], 4).unwrap(); + assert_eq!(ccm.y, Ccm::::format_b0(&nonce, false, 4)); + assert_eq!(ccm.mac_pos, 0, "a whole block was absorbed, so nothing is part-filled"); + } + + /// Appendix C.4: `Tlen=112, Nlen=104, Plen=256`, so `t = 14`, `n = 13`, `q = 2`; the spec + /// prints `B0` as `71101112 13141516 1718191a 1b1c0020`. + /// + /// This is the other end of the `q` range from C.1, so between them the two tests pin the + /// `[q-1]_3` encoding and the fact that `Q` is `q` octets wide, not a fixed width. + #[test] + fn c4_b0_matches_the_spec() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c]; + assert_eq!( + Ccm::::format_b0(&nonce, true, 32), + [ + 0x71, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x00, 0x20 + ], + "C.4 B0: flags 0x71 = Adata 1 | [(14-2)/2]_3 = 110 | [2-1]_3 = 001, then Q = [32]_16" + ); + } + + /// Appendix C.1 prints `Ctr0` as `07101112 13141516 00000000 00000000` and `Ctr1` as the same + /// with a trailing `01`; C.4's are `01101112 ... 1b1c0000` and `... 1b1c0001`. + /// + /// Table 4 makes the counter flags `[q-1]_3` alone, with every other bit zero -- which is what + /// keeps them distinct from `B0`, whose `t` field cannot be zero. + #[test] + fn counter_blocks_match_the_spec() { + let nonce_c1 = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let mut ccm = + Ccm::::new(&key(), &nonce_c1, &[], 4).unwrap(); + // `Ctr0` is the template with a zero counter field. + let mut ctr0 = ccm.ctr_template; + Ccm::::put_q_field(&mut ctr0, 0); + assert_eq!( + ctr0, + [0x07, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0, 0, 0, 0, 0, 0, 0, 0], + "C.1 Ctr0" + ); + // The first payload keystream block is `S1`, so one refill must produce `Ctr1`. + ccm.refill_keystream(); + let mut ctr1 = ctr0; + ctr1[15] = 1; + assert_eq!(*ccm.ks, ctr1, "C.1 Ctr1 (the identity permutation leaves S1 = Ctr1)"); + + let nonce_c4 = + [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c]; + let ccm4 = + Ccm::::new(&key(), &nonce_c4, &[], 32).unwrap(); + let mut ctr0_c4 = ccm4.ctr_template; + Ccm::::put_q_field(&mut ctr0_c4, 0); + assert_eq!( + ctr0_c4, + [ + 0x01, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x00, 0x00 + ], + "C.4 Ctr0" + ); + } + + /// A.2.2's three AAD length encodings, at and around both boundaries. + /// + /// Two of these values come from the spec itself: C.1's `a = 8` is printed as `0008`, and + /// C.4's `a = 65536` (`Alen = 524288` bits) is printed as + /// `11111111 11111110 00000000 00000001 00000000 00000000`, i.e. `ff fe 00 01 00 00`. + /// + /// The rest pin the boundaries, which is the part no end-to-end test can reach: the first is + /// `2^16 - 2^8` = 65280 rather than the obvious-but-wrong `2^16`, and the second is `2^32`, + /// which through the public API would need a 4 GiB AAD. + #[test] + fn aad_length_encoding_matches_a_2_2() { + type Mode = Ccm; + + // Case 1: 0 < a < 2^16 - 2^8, two octets, `[a]_16`. + assert_eq!( + Mode::encode_aad_len(8), + ([0x00, 0x08, 0, 0, 0, 0, 0, 0, 0, 0], 2), + "C.1's a = 8" + ); + assert_eq!(Mode::encode_aad_len(1).1, 2); + // 65279 = 2^16 - 2^8 - 1 is the largest value still in the first case. + assert_eq!( + Mode::encode_aad_len(65279), + ([0xfe, 0xff, 0, 0, 0, 0, 0, 0, 0, 0], 2), + "65279 is still [a]_16" + ); + + // Case 2: 2^16 - 2^8 <= a < 2^32, six octets, `0xff || 0xfe || [a]_32`. 65280 is the first. + assert_eq!( + Mode::encode_aad_len(65280), + ([0xff, 0xfe, 0x00, 0x00, 0xff, 0x00, 0, 0, 0, 0], 6), + "65280 crosses into the six-octet case; a two-octet 0xff00 would be ambiguous" + ); + assert_eq!( + Mode::encode_aad_len(65536), + ([0xff, 0xfe, 0x00, 0x01, 0x00, 0x00, 0, 0, 0, 0], 6), + "C.4's a = 65536" + ); + // 2^32 - 1 is the largest value still in the second case. + assert_eq!( + Mode::encode_aad_len(u32::MAX as u64), + ([0xff, 0xfe, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0], 6), + "2^32 - 1 is still the six-octet case" + ); + + // Case 3: 2^32 <= a < 2^64, ten octets, `0xff || 0xff || [a]_64`. + assert_eq!( + Mode::encode_aad_len(1u64 << 32), + ([0xff, 0xff, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00], 10), + "2^32 is the first ten-octet case" + ); + assert_eq!( + Mode::encode_aad_len(u64::MAX), + ([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], 10) + ); + + // A.2.2's whole point: the three cases are distinguishable by their leading octets, so no + // two distinct lengths can encode to the same prefix. The first octet is 0xff only in the + // second and third cases, and the second octet separates those. + for a in [1u64, 8, 65279] { + assert_ne!(Mode::encode_aad_len(a).0[0], 0xff, "case 1 must not lead with 0xff"); + } + } + + /// The constructor really uses [`Ccm::encode_aad_len`], and puts it *before* the AAD. + /// + /// With the identity permutation the CBC-MAC is `y = B0 ^ B1 ^ ... ^ Br`, so with a one-block + /// all-zero AAD the only nonzero contributions are `B0` and the length encoding. That makes the + /// encoding readable back out, which is what pins the ordering rather than just the value. + #[test] + fn the_constructor_prefixes_the_aad_with_its_length() { + type Mode = Ccm; + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + // 14 zero bytes of AAD: the 2-byte length plus 14 bytes is exactly one 16-byte block, so + // there is no padding to reason about. + let ccm = Mode::new(&key(), &nonce, &[0u8; 14], 0).unwrap(); + + let b0 = Mode::format_b0(&nonce, true, 0); + let mut b1 = [0u8; 16]; + b1[..2].copy_from_slice(&14u16.to_be_bytes()); + let expected: [u8; 16] = core::array::from_fn(|i| b0[i] ^ b1[i]); + assert_eq!(ccm.y, expected, "y must be B0 ^ B1, with B1 starting with [14]_16"); + } + + /// A.1's `p < 2^8q`. With `n = 13`, `q = 2`, so the limit is 65535 and 65536 must be refused. + #[test] + fn payload_longer_than_the_q_limit_is_refused() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c]; + assert!( + Ccm::::new(&key(), &nonce, &[], 65535).is_ok(), + "2^16 - 1 is the largest payload q = 2 can encode" + ); + assert!( + matches!( + Ccm::::new(&key(), &nonce, &[], 65536), + Err(SymmetricCipherError::GenericError(_)) + ), + "2^16 does not fit [p]_16" + ); + } + + /// The declared payload length is inside `B0`, so neither direction may be finalized with the + /// wrong amount of data. + #[test] + fn a_short_or_long_payload_is_refused() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let mut ccm = + Ccm::::new(&key(), &nonce, &[], 8).unwrap(); + let mut too_much = [0u8; 9]; + assert!( + matches!( + ccm.do_encrypt_update(&mut too_much), + Err(SymmetricCipherError::StateError(_)) + ), + "9 bytes against a declared 8" + ); + let mut some = [0u8; 4]; + ccm.do_encrypt_update(&mut some).expect("4 of the 8 declared bytes"); + assert!( + matches!(ccm.do_encrypt_final(), Err(SymmetricCipherError::StateError(_))), + "finalizing 4 bytes short" + ); + } + + /// The two directions absorb the *plaintext* into the CBC-MAC, in both cases: Sec 6.1 step 1 + /// formats `P` and Sec 6.2 step 7 formats the recovered `P`, never the ciphertext. So an + /// encryptor and a decryptor over the same message must reach the same `Yr`, and therefore the + /// same tag, even though they apply the keystream and the MAC in the opposite order. + /// + /// This is the property the wrong-direction runtime check used to guard; the `Dir` parameter + /// now makes the misuse a compile error (see the `compile_fail` examples on `Ccm`), so what is + /// left worth testing is that the two orders genuinely agree. + #[test] + fn both_directions_mac_the_plaintext() { + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let plaintext = [0xDEu8, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + + let mut enc = + Ccm::::new(&key(), &nonce, b"h", plaintext.len()) + .unwrap(); + let mut data = plaintext; + enc.do_encrypt_update(&mut data).unwrap(); + let tag = enc.do_encrypt_final().unwrap(); + + // The decryptor is handed the ciphertext, recovers the plaintext, and must agree on the tag. + let mut dec = + Ccm::::new(&key(), &nonce, b"h", plaintext.len()) + .unwrap(); + dec.do_decrypt_update(&mut data).unwrap(); + dec.do_decrypt_final(&tag).expect("the two directions must reach the same Yr"); + assert_eq!(data, plaintext); + } +} diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs index aeed1ee3..c3de853f 100644 --- a/crypto/modes/src/lib.rs +++ b/crypto/modes/src/lib.rs @@ -1,4 +1,4 @@ -//! Block cipher modes of operation (NIST SP 800-38A). +//! Block cipher modes of operation (NIST SP 800-38A and SP 800-38C). //! //! A mode turns a keyed block permutation -- `bouncycastle-aes`'s `AES_128` and friends, //! or anything else implementing [`ElectronicCodeBook`] -- into something that can encrypt more than @@ -11,20 +11,40 @@ //! | CFB | [`Cfb`] | SP 800-38A Sec 6.3 | Cipher Feedback, full-block segment (`s = b`), i.e. CFB128 for AES | //! | CFB8 | [`Cfb8`] | SP 800-38A Sec 6.3 | Cipher Feedback, 8-bit segment (`s = 8`) | //! | CTR | [`Ctr`] | SP 800-38A Sec 6.5 | Counter. Nonce plus counter, both directions parallel | -//! -//! They divide two ways. **ECB and CBC are block ciphers** ([`BlockCipherEncryptor`] / -//! [`BlockCipherDecryptor`]): whole blocks in, whole blocks out, and arbitrary-length data needs -//! the padding layer. **CFB, CFB8 and CTR are stream ciphers** ([`StreamCipherEncryptor`] / -//! [`StreamCipherDecryptor`]): any length in, the same length out, no padding, no finalization -- -//! see [Block alignment, and which modes need it](#block-alignment-and-which-modes-need-it). -//! -//! **All five reach the same arbitrary-length API**, so code can be written against one trait and -//! handed any mode. A block mode gets there by being wrapped in `bouncycastle-padding`'s adapters, -//! which are [`SymmetricCipherEncryptor`] / [`SymmetricCipherDecryptor`] with the padded block as -//! their final output; a stream mode implements those traits directly, with `FINAL_LEN = 0` because -//! it has no final output at all. The `bouncycastle-aes` aliases show the difference in -//! one line each: `AES_CBC_128` names a padding scheme, `AES_CTR_128` -//! has nothing to name. +//! | CCM | [`Ccm`] | SP 800-38C | Counter with CBC-MAC. **The only authenticated mode here**: CTR plus CBC-MAC, with a tag and AAD | +//! +//! They divide three ways. +//! +//! **ECB and CBC are block ciphers** ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]): whole +//! blocks in, whole blocks out, and arbitrary-length data needs the padding layer. **CFB, CFB8 and +//! CTR are stream ciphers** ([`StreamCipherEncryptor`] / [`StreamCipherDecryptor`]): any length in, +//! the same length out, no padding, no finalization -- see +//! [Block alignment, and which modes need it](#block-alignment-and-which-modes-need-it). +//! +//! **Those five reach the same arbitrary-length API**, so code can be written against one trait and +//! handed any of them. A block mode gets there by being wrapped in `bouncycastle-padding`'s +//! adapters, which are [`SymmetricCipherEncryptor`] / [`SymmetricCipherDecryptor`] with the padded +//! block as their final output; a stream mode implements those traits directly, with +//! `FINAL_LEN = 0` because it has no final output at all. The `bouncycastle-aes` aliases show the +//! difference in one line each: `AES_CBC_128` names a padding scheme, +//! `AES_CTR_128` has nothing to name. +//! +//! **CCM is the odd one out, and deliberately so.** It is an AEAD: it takes additional +//! authenticated data, and it produces a tag as well as a ciphertext, so it does not fit either of +//! the traits above -- there is nowhere in them to put the AAD or the tag. It implements +//! [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] instead (through [`CcmEncryptor`] / +//! [`CcmDecryptor`]), and its own inherent API is the one to reach for. Two other things set it +//! apart: +//! +//! * **There is an extra input and an extra output.** The AAD is authenticated but not encrypted, +//! and the tag has to travel with the ciphertext; `Ccm` offers both the spec's inline +//! `ciphertext || tag` layout and a detached-tag pair. +//! * **The nonce is supplied, not generated.** CCM requires the nonce to be unique but *not* +//! unpredictable (SP 800-38C Sec 5.3), which is the opposite of the IV requirement the other +//! modes have, so a caller with a counter can do better than this crate's DRBG. +//! +//! See [`Ccm`] for both, and [Choosing between the modes](#choosing-between-the-modes) for when it +//! is the right answer -- which, for a new design, is usually. //! //! CBC, CFB, CFB8 and CTR all generate their own init data: an IV for the first three, a nonce for //! CTR, which is shorter than a block because the rest of the counter block is the counter. ECB has @@ -38,14 +58,15 @@ //! //! The crate is deliberately cipher-agnostic: it depends on no concrete block cipher, only on the //! trait. Define a one-line alias for the combination you use -- or use the ready-made -//! `AES_CBC_128` / `AES_CFB_128` / `AES_CFB8_128` / `AES_CTR_128` / `AES_ECB_128` and friends from -//! `bouncycastle-aes`. Those aliases are not all the same shape: the two block modes take -//! a padding scheme as well as a direction, since neither is usable on data of arbitrary length -//! without one, while the three stream modes take only the direction: +//! `AES_CBC_128` / `AES_CCM_128` / `AES_CFB_128` / `AES_CFB8_128` / `AES_CTR_128` / `AES_ECB_128` +//! and friends from `bouncycastle-aes`. Those aliases are not all the same shape: the two block +//! modes take a padding scheme as well as a direction, since neither is usable on data of arbitrary +//! length without one, the three stream modes take only the direction, and CCM takes no direction +//! at all but does take its nonce and tag lengths: //! //! ``` //! use bouncycastle_aes::{AES_128, AES_192, AES_256}; -//! use bouncycastle_modes::{Cbc, Cfb, Cfb8, Ctr, Ecb}; +//! use bouncycastle_modes::{Cbc, Ccm, Cfb, Cfb8, Ctr, Ecb}; //! //! type Aes128Cbc = Cbc; //! type Aes192Cbc = Cbc; @@ -62,6 +83,15 @@ //! type Aes128Ctr = Ctr; //! //! type Aes128Ecb = Ecb; +//! +//! // CCM takes the direction like the rest, plus the nonce length and the tag length -- both +//! // real cryptographic choices rather than AES constants. The nonce length caps the payload +//! // (SP 800-38C A.1: `n + q = 15`, `p < 2^8q`) and the tag length is the forgery bound; +//! // 12 and 16 are the usual pair. +//! type Aes128Ccm = Ccm; +//! type Aes256Ccm = Ccm; +//! // A 13-byte nonce leaves q = 2, so a payload of at most 64 KiB - 1; 802.11 CCMP's pair. +//! type Aes128CcmShortTag = Ccm; //! ``` //! //! # Usage Examples @@ -212,6 +242,42 @@ //! assert_eq!(data, plaintext); //! ``` //! +//! CCM is shaped differently from all of the above, because it is the only authenticated one. There +//! is no direction parameter, the nonce is supplied rather than generated, and there is an extra +//! input (the AAD, authenticated but not encrypted) and an extra output (the tag). Decryption +//! either returns the plaintext or fails -- it never returns plausible-looking rubbish the way the +//! unauthenticated modes do when the ciphertext has been altered: +//! +//! ``` +//! use bouncycastle_aes::AES_128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_modes::{Ccm, Decrypting, Encrypting}; +//! +//! type Aes128Ccm = Ccm; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +//! .expect("a 16-byte symmetric cipher key"); +//! // Supplied, not generated -- and it must never repeat under this key. +//! let nonce = [0x01u8; 12]; +//! let header = b"authenticated, not encrypted"; +//! let message = b"any length: CCM pads internally"; +//! +//! // The spec's own layout (SP 800-38C Sec 6.1 step 8): `ciphertext || tag`. +//! let mut sealed = vec![0u8; message.len() + 16]; +//! Aes128Ccm::::encrypt(&key, &nonce, header, message, &mut sealed).expect("encryption"); +//! +//! let mut opened = vec![0u8; message.len()]; +//! let n = Aes128Ccm::::decrypt(&key, &nonce, header, &sealed, &mut opened).expect("decryption"); +//! assert_eq!(&opened[..n], message); +//! +//! // Any change to the ciphertext, the tag, the header or the nonce is detected -- which is the +//! // whole difference from the five modes above. +//! let mut tampered = sealed.clone(); +//! tampered[0] ^= 1; +//! assert!(Aes128Ccm::::decrypt(&key, &nonce, header, &tampered, &mut opened).is_err()); +//! assert!(Aes128Ccm::::decrypt(&key, &nonce, b"other header", &sealed, &mut opened).is_err()); +//! ``` +//! //! Using the wrong direction does not compile: //! //! ```compile_fail @@ -229,8 +295,31 @@ //! //! # Choosing between the modes //! -//! None is authenticated, so the honest answer for new designs is "none of them -- use an AEAD". -//! ECB is not a candidate for data at all (below). Between the rest: +//! **For a new design, use [`Ccm`].** It is the only authenticated mode here, and an +//! unauthenticated mode is almost never what a new protocol wants: the other five leave the +//! ciphertext malleable in the specific, exploitable ways set out in +//! [None of the other modes is authenticated](#none-of-the-other-modes-is-authenticated), and +//! bolting a MAC on afterwards is a design most people get wrong. CCM's costs, so that the choice +//! is informed rather than reflexive: +//! +//! * **Two cipher calls per block, and no batching.** CCM runs both CTR and a CBC-MAC over the same +//! data (Sec 5.2), and the CBC-MAC is serial, so it cannot use the permutation's pair or four +//! path. This crate's benches measure it at about half CTR's unbatched throughput and a quarter +//! of CTR's batched. +//! * **It does not stream.** SP 800-38C Sec 3: "CCM is not designed to support partial processing +//! or stream processing", because the payload length is inside the first block the MAC covers. +//! `Ccm` handles that by taking the length up front, which costs nothing; code written against +//! the generic AEAD traits pays for it in buffering instead. See [`Ccm`]. +//! * **The payload is capped** by the nonce length, at `2^(8 * (15 - NONCE_LEN)) - 1` bytes. +//! * **The nonce must be unique.** Reuse is worse than for CTR: it loses confidentiality *and* +//! enables forgery. +//! +//! If CCM's shape does not fit -- a genuinely streaming multi-gigabyte input, say -- +//! `bouncycastle-ascon`'s Ascon-AEAD128 is an AEAD that does stream. Choosing an unauthenticated +//! mode from this crate should be a deliberate decision, made because an existing format or spec +//! requires it, and paired with separate authentication. +//! +//! ECB is not a candidate for data at all (below). Between the five unauthenticated modes: //! //! * **Only CBC needs padding.** CFB and CFB8 are stream ciphers: any length in, the same length //! out. CBC needs the data padded to a whole number of blocks, which means a padding layer and @@ -320,7 +409,9 @@ //! //! No heap allocation, and no lookup tables of its own. A CBC or CFB8 value is the permutation plus //! one block of chaining value; a CFB value adds a `usize` to that; a CTR value carries the nonce, -//! a counter and a keystream block; an ECB value is just the permutation, since nothing chains: +//! a counter and a keystream block; an ECB value is just the permutation, since nothing chains; a +//! CCM value carries three blocks (the CBC-MAC chaining value, the counter template and the +//! keystream) plus four counters, because it runs two mechanisms at once: //! //! ```text //! size_of::>() == size_of::

() + BLOCK_LEN @@ -331,6 +422,16 @@ //! // CTR, rounded up to the counter's 8-byte alignment: //! size_of::>() //! == align8(size_of::

() + NONCE_LEN + 8 + BLOCK_LEN + 8) +//! +//! // CCM. Independent of NONCE_LEN and TAG_LEN: the nonce lives inside the counter template and +//! // the tag is built at finalization, so neither adds a field. `Dir` is zero-sized. +//! size_of::>() +//! == align8(size_of::

() + 3 * BLOCK_LEN + 3 * size_of::() + 8) +//! +//! // The buffering AEAD-trait adapters, which is where CCM gets expensive: two BUFFER_LEN +//! // arrays, and the trait's one-shots put a third of the same size on the stack. +//! size_of::>() +//! == align8(size_of::

() + 2 * BUFFER_LEN + NONCE_LEN + 2 * size_of::() + 1) //! ``` //! //! | Combination | Permutation | Chain | Count | Total | @@ -347,6 +448,25 @@ //! | AES-128 ECB | 176 B | 0 B | -- | 176 B | //! | AES-192 ECB | 208 B | 0 B | -- | 208 B | //! | AES-256 ECB | 240 B | 0 B | -- | 240 B | +//! | AES-128 CCM | 176 B | 16 B MAC + 16 B counter template + 16 B keystream | 32 B | 256 B | +//! | AES-192 CCM | 208 B | 48 B, as above | 32 B | 288 B | +//! | AES-256 CCM | 240 B | 48 B, as above | 32 B | 320 B | +//! +//! CCM is the largest of the streaming values, because it is the only mode running two mechanisms +//! at once: the CBC-MAC needs its chaining value, and the CTR half needs both a keystream block and +//! the counter template that generates it. It is **independent of `NONCE_LEN` and `TAG_LEN`** -- +//! `Ccm` and `Ccm` are both 256 B -- +//! because the nonce is stored inside the counter template rather than separately, and the tag is +//! assembled at finalization rather than held. +//! +//! **[`CcmEncryptor`] and [`CcmDecryptor`] are a different order of magnitude**, and that is the +//! one memory figure in this crate worth thinking about before choosing an API. They buffer the +//! whole message, so at `BUFFER_LEN = 2048` an AES-128 encryptor is **4304 B**, and the AEAD +//! trait's one-shots put another `BUFFER_LEN` on the stack as the finalization buffer -- about +//! `3 * BUFFER_LEN` in total for a call to `encrypt_out`. Using [`Ccm`] directly costs 264 B +//! whatever the message length, and the benches measure no throughput difference between the two, +//! so the buffering pair is worth it only when the generic trait is genuinely needed. See [`Ccm`] +//! for why the buffering cannot be avoided in the trait. //! //! CFB8 is the same size as CBC because it stores the same thing: one block of input to the next //! cipher call. CFB adds one `usize` because its segment is a whole block and a call may end @@ -391,9 +511,15 @@ //! data. If you find yourself reaching for it because it needs no IV, that is the problem the IV //! solves. //! -//! ## None of the modes is authenticated +//! ## None of the other modes is authenticated +//! +//! This section is about the five SP 800-38A modes. **[`Ccm`] is exempt**: it is an AEAD, its tag +//! covers the payload, the AAD and the nonce, and decryption returns `Err` rather than plaintext if +//! any of them has been altered. Everything below is a description of what you give up by choosing +//! one of the other five, and the reason +//! [Choosing between the modes](#choosing-between-the-modes) starts with CCM. //! -//! All four provide, at best, confidentiality only. None detects tampering, and each is malleable +//! Those five provide, at best, confidentiality only. None detects tampering, and each is malleable //! in specific, exploitable ways -- SP 800-38A Appendix D, Table D.2, whose CFB row is //! "SBE in the decryption of `Cj`" plus "RBE in the decryption of `Cj+1`,...,`Cj+b/s`" (SBE = //! specific bit errors, the same positions; RBE = random bit errors): @@ -415,8 +541,9 @@ //! CFB8 is chosen for -- and it also means a tampered byte damages a bounded, predictable window //! rather than the rest of the message. //! -//! **Authenticate the ciphertext.** Prefer an AEAD; if you must use one of these, MAC the -//! ciphertext *and* the IV, and verify before decrypting. +//! **Authenticate the ciphertext.** Prefer an AEAD -- [`Ccm`] is in this crate, and needs no +//! separate MAC, no key-separation decision and no encrypt-then-MAC ordering care. If you must use +//! one of the five, MAC the ciphertext *and* the IV, and verify before decrypting. //! //! Combining decryption with a padding check is the classic padding-oracle setup. It applies to CBC //! here, the one mode that needs padding; do not report padding failures distinguishably, and do @@ -483,16 +610,25 @@ //! * **CFB1**, the `s = 1` segment size (SP 800-38A Appendix F.3.1-F.3.6). Its segment is a single //! *bit*, so unlike [`Cfb`] and [`Cfb8`] it does not fit a byte-oriented API at all: a message is //! a bit string whose length need not be a multiple of 8, which this crate has no type for. -//! * **OFB**, the one remaining mode of the recommendation. It is a keystream mode and, like CFB, +//! * **OFB**, the one remaining mode of SP 800-38A. It is a keystream mode and, like CFB, //! CFB8 and CTR, would implement [`StreamCipherEncryptor`] / [`StreamCipherDecryptor`]. +//! * **GCM** (SP 800-38D), the other widely-used AEAD mode of a block cipher. It would sit +//! alongside [`Ccm`] on [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`], and unlike CCM it +//! streams, but it needs GF(2^128) multiplication, which this crate has no support for. +//! * **CCM with a formatting function other than Appendix A's.** SP 800-38C Sec 5.4 allows +//! alternatives and says "Alternative formatting functions may be developed in the future"; +//! Appendix A's is the only one that exists in practice and the only one [`Ccm`] implements. //! //! # Command line //! -//! The `bc-rust` CLI exposes all five modes for all three AES key lengths: `aes{128,192,256}-cbc`, -//! `-cfb`, `-cfb8`, `-ctr` and `-ecb`, each taking `encrypt` or `decrypt` and streaming stdin to -//! stdout. There is no API for caller-supplied init data anywhere, so `encrypt` writes what it -//! generated at the front of its output and `decrypt` reads it back, and the two compose. That is -//! one block for CBC, CFB and CFB8, **12 bytes** for CTR, and nothing at all for `-ecb`: +//! The `bc-rust` CLI exposes all six modes for all three AES key lengths: `aes{128,192,256}-cbc`, +//! `-ccm`, `-cfb`, `-cfb8`, `-ctr` and `-ecb`, each taking `encrypt` or `decrypt`. All but `-ccm` +//! stream stdin to stdout; see below for why CCM cannot. +//! +//! For the five unauthenticated modes there is no API for caller-supplied init data anywhere, so +//! `encrypt` writes what it generated at the front of its output and `decrypt` reads it back, and +//! the two compose. That is one block for CBC, CFB and CFB8, **12 bytes** for CTR, and nothing at +//! all for `-ecb`: //! //! ```text //! bc-rust aes256-cbc encrypt --key-file k.bin < plain.bin > cipher.bin @@ -511,12 +647,36 @@ //! [`Cfb8`]; the two are not interoperable. The `-ctr` commands use a 12-byte nonce and so a 4-byte //! counter, matching `AES_CTR_*`. Input must be block-aligned for the `-cbc` and `-ecb` commands, //! and may be any length for `-cfb`, `-cfb8` and `-ctr`, for the reason given above. +//! +//! **`-ccm` is different in three visible ways**, all of them following from CCM being an AEAD: +//! +//! ```text +//! # The nonce is a flag, and the same one is needed to decrypt: CCM needs it unique, not +//! # unpredictable (SP 800-38C Sec 5.3), so the caller chooses it. +//! bc-rust aes256-ccm encrypt --key-file k.bin --nonce 000102030405060708090a0b \ +//! --aad cafebabe < plain.bin > sealed.bin +//! bc-rust aes256-ccm decrypt --key-file k.bin --nonce 000102030405060708090a0b \ +//! --aad cafebabe < sealed.bin | cmp - plain.bin +//! ``` +//! +//! 1. **`--nonce` / `--nonce-file` is required and is not written to the output**, unlike every +//! other mode's generated IV. `--aad` adds data that is authenticated but not encrypted, and +//! must match on both sides. `--tag-len` selects the tag length, defaulting to 16. +//! 2. **The output is `--tag-len` bytes longer than the input** (`ciphertext || tag`, Sec 6.1 +//! step 8), and `decrypt` **fails with a non-zero exit** rather than emitting rubbish if +//! anything has been altered. +//! 3. **It does not stream**: it reads all of stdin before doing any work, so memory use is +//! proportional to the input. That is Sec 3's "CCM is not designed to support partial processing +//! or stream processing", not a limitation of this implementation. It does buy something, +//! though -- no plaintext is written until the tag has verified, so a failed `decrypt` leaves +//! nothing to discard. For a streaming AEAD use `bc-rust ascon-aead128`. #![no_std] #![forbid(unsafe_code)] #![forbid(missing_docs)] mod cbc; +mod ccm; mod cfb; mod cfb8; mod ctr; @@ -524,6 +684,7 @@ mod ecb; mod iv; pub use cbc::Cbc; +pub use ccm::{Ccm, CcmDecryptor, CcmEncryptor}; pub use cfb::Cfb; pub use cfb8::Cfb8; pub use ctr::Ctr; @@ -532,6 +693,7 @@ pub use ecb::Ecb; // Imports needed for docs #[allow(unused_imports)] use bouncycastle_core::traits::{ + AEADCipherDecryptor, AEADCipherEncryptor, BlockCipherDecryptor, BlockCipherEncryptor, ElectronicCodeBook, StreamCipherDecryptor, StreamCipherEncryptor, SymmetricCipherDecryptor, SymmetricCipherEncryptor, }; diff --git a/crypto/modes/tests/acvp_ccm_tests.rs b/crypto/modes/tests/acvp_ccm_tests.rs new file mode 100644 index 00000000..a5c3c819 --- /dev/null +++ b/crypto/modes/tests/acvp_ccm_tests.rs @@ -0,0 +1,371 @@ +//! Known-answer tests against the NIST ACVP `ACVP-AES-CCM` vectors from the `bc-test-data` repo. +//! +//! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` +//! relative to the root of this git project. If it is absent the test prints a warning and passes, +//! matching the convention used by the other ACVP suites -- `cargo test` must stay green for +//! someone who has only cloned this repository. +//! +//! # The tag is inline, so this drives the inline API +//! +//! The set has **no `tag` field anywhere**. An encrypt group's answer `ct` is the ciphertext with +//! the tag appended, and a decrypt group's input `ct` is the same, which is exactly SP 800-38C +//! Sec 6.1 step 8's own output string. So the cases go through [`Ccm::encrypt`] / [`Ccm::decrypt`], +//! the inline pair, and the group's `payloadLen` / `tagLen` are only needed to pick `TAG_LEN` and +//! to check the answer's length. +//! +//! # Failure cases are part of the vectors +//! +//! 52 of the 240 decrypt cases are inauthentic, and the response file marks them with +//! `"testPassed": false` and no `pt`. There is no `decryptVerificationFailed` field in this set. +//! Those cases are run and required to come back +//! [`AEADTagCheckFailed`](SymmetricCipherError::AEADTagCheckFailed) -- they are the only official +//! negative vectors this library has for CCM, so they are checked, not skipped. +//! +//! # Joining the request and response files +//! +//! As with the other AES sets, the response file carries only the answer against a `tcId`; the key, +//! nonce, AAD and input live in the request file, and so does the group metadata that says which +//! direction a case is. Both files are read and joined on `tcId`, which is unique across the whole +//! set. +//! +//! # What this set does *not* cover +//! +//! Worth stating, so the gaps stay visible rather than looking like coverage: +//! +//! * **`ivLen` is 96 in every group**, so `n = 12` and `q = 3` throughout. The nonce-length / +//! payload-limit tradeoff of A.1 is entirely untested here; `sp800_38c_tests.rs` covers `q` of 8, +//! 7, 3 and 2 against Appendix C. +//! * **`tagLen` is only 96 or 128.** The short tags A.1 permits (`t` of 4 or 6) appear in Appendix +//! C instead. +//! * **No empty AAD and no empty payload**: `aadLen` is 128 or 256 bits and `payloadLen` is 64, +//! 128 or 192. Sec 5.3 permits both to be empty, and `sp800_38c_tests.rs` covers that. +//! * **Every payload is 8, 16 or 24 bytes**, i.e. one or two blocks, so nothing here stresses a +//! long message. The `chunks` sweep below and the Appendix C.4 case cover the multi-block paths. +//! +//! The 6 Monte Carlo groups that the CTR and CBC sets have do not exist here: every group in this +//! set is `testType: "AFT"`, so nothing is skipped for that reason. + +use bouncycastle_aes::{AES_128, AES_192, AES_256}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::{ElectronicCodeBook, SecurityStrength}; +use bouncycastle_hex as hex; +use bouncycastle_modes::{Ccm, Decrypting, Encrypting}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Every group in this set has `ivLen: 96`. +const NONCE_LEN: usize = 12; + +/// Candidate locations, covering `cargo test` run from the crate root or from the repo root. +const TEST_DATA_PATHS: [&str; 2] = [ + "../../../bc-test-data/crypto/aes_tdes_vectors/CCM", + "../bc-test-data/crypto/aes_tdes_vectors/CCM", +]; + +const REQUEST_FILE: &str = "ACVP-AES-CCM.4014548.req.json"; +const RESPONSE_FILE: &str = "ACVP-AES-CCM.4014548.rsp.json"; + +fn test_data_dir() -> Option { + for candidate in TEST_DATA_PATHS { + let path = Path::new(candidate); + if path.join(REQUEST_FILE).exists() && path.join(RESPONSE_FILE).exists() { + return Some(path.to_path_buf()); + } + } + println!( + "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ + ACVP AES-CCM tests will be skipped" + ); + None +} + +fn decode(value: &Value, field: &str, tc_id: u64) -> Vec { + let s = value + .get(field) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {field}")); + hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {field}")) +} + +/// Wraps the vector's raw key bytes, promoting them if `KeyMaterial`'s entropy heuristic declined +/// to call them a cipher key. Same helper as the other ACVP suites in this crate. +fn cipher_key(bytes: &[u8]) -> KeyMaterial { + assert_eq!(bytes.len(), N, "key length should match the parameter set"); + let mut key = KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("ACVP key bytes fit the buffer"); + + if key.key_type() != KeyType::SymmetricCipherKey { + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::from_bytes(N)) + }) + .expect("promoting a NIST test key"); + } + key +} + +/// The outcome of one decrypt case, so that an expected authentication failure can be asserted +/// rather than merely tolerated. +enum Decrypted { + Plaintext(Vec), + TagCheckFailed, +} + +/// Runs one encrypt case: `Ccm::encrypt` must produce the response file's `ct`, which is +/// `ciphertext || tag`. +/// +/// Also re-runs it through the length-declared streaming API in several chunkings, since these are +/// the only real vectors available for that path and the one-shot is a single call over the whole +/// payload. +fn encrypt_case( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> Vec +where + P: ElectronicCodeBook, +{ + let mut inline = vec![0u8; plaintext.len() + TAG_LEN]; + let written = Ccm::::encrypt( + key, nonce, aad, plaintext, &mut inline, + ) + .expect("CCM encryption of a valid ACVP case"); + assert_eq!(written, inline.len(), "the inline layout writes ciphertext || tag"); + + // The same answer must come out of the streaming API, in any chunking of both phases. + for chunk in [1usize, 5, 16] { + let mut ccm = Ccm::::new( + key, + nonce, + aad, + plaintext.len(), + ) + .expect("streaming init"); + let mut streamed = plaintext.to_vec(); + for piece in streamed.chunks_mut(chunk) { + ccm.do_encrypt_update(piece).expect("update"); + } + let tag = ccm.do_encrypt_final().expect("final"); + assert_eq!(&streamed[..], &inline[..plaintext.len()], "streamed in {chunk}-byte chunks"); + assert_eq!(&tag[..], &inline[plaintext.len()..], "streamed tag, {chunk}-byte chunks"); + } + + inline +} + +/// Runs one decrypt case over the inline `ciphertext || tag` string the vectors carry. +fn decrypt_case( + key: &KeyMaterial, + nonce: &[u8; NONCE_LEN], + aad: &[u8], + ct_and_tag: &[u8], +) -> Decrypted +where + P: ElectronicCodeBook, +{ + let mut plaintext = vec![0u8; ct_and_tag.len().saturating_sub(TAG_LEN)]; + match Ccm::::decrypt( + key, nonce, aad, ct_and_tag, &mut plaintext, + ) { + Ok(n) => { + plaintext.truncate(n); + Decrypted::Plaintext(plaintext) + } + Err(SymmetricCipherError::AEADTagCheckFailed) => { + assert!( + plaintext.iter().all(|b| *b == 0), + "Sec 6.2: the payload must not be revealed when the check fails" + ); + Decrypted::TagCheckFailed + } + Err(other) => panic!("unexpected CCM decryption error: {other:?}"), + } +} + +/// Dispatches a case to the right `(KEY_LEN, TAG_LEN)` instantiation. +/// +/// Both are const generics, so the six combinations this set uses are spelled out. `ivLen` is 96 in +/// every group, so `NONCE_LEN` is not part of the dispatch; an unexpected value is a hard failure +/// rather than a silent skip, so that a future revision of the vector file cannot quietly reduce +/// coverage. +#[allow(clippy::too_many_arguments)] +fn run_case( + tc_id: u64, + key_len: u64, + tag_len: u64, + encrypt: bool, + key_bytes: &[u8], + nonce: &[u8; NONCE_LEN], + aad: &[u8], + input: &[u8], +) -> Result, ()> { + macro_rules! dispatch { + ($k:literal, $t:literal, $p:ty) => {{ + let key = cipher_key::<$k>(key_bytes); + if encrypt { + Ok(encrypt_case::<$k, $t, $p>(&key, nonce, aad, input)) + } else { + match decrypt_case::<$k, $t, $p>(&key, nonce, aad, input) { + Decrypted::Plaintext(p) => Ok(p), + Decrypted::TagCheckFailed => Err(()), + } + } + }}; + } + + // A macro here rather than the unrolled six arms purely because the *type* arguments differ: + // `KEY_LEN`, `TAG_LEN` and the AES type all vary together, and a function cannot take them as + // runtime values. The body is one expression, and each arm is its own instantiation, so + // `cargo mutants` still sees the code it expands to. + match (key_len, tag_len) { + (128, 96) => dispatch!(16, 12, AES_128), + (128, 128) => dispatch!(16, 16, AES_128), + (192, 96) => dispatch!(24, 12, AES_192), + (192, 128) => dispatch!(24, 16, AES_192), + (256, 96) => dispatch!(32, 12, AES_256), + (256, 128) => dispatch!(32, 16, AES_256), + other => panic!("tcId {tc_id}: unexpected (keyLen, tagLen) {other:?}"), + } +} + +#[test] +fn acvp_aes_ccm_known_answer_tests() { + let Some(dir) = test_data_dir() else { return }; + + let req: Value = serde_json::from_str( + &fs::read_to_string(dir.join(REQUEST_FILE)).expect("readable request file"), + ) + .expect("valid ACVP request JSON"); + let rsp: Value = serde_json::from_str( + &fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"), + ) + .expect("valid ACVP response JSON"); + + // The response file carries only the answer, against a tcId. Index it. + let mut answers: BTreeMap = BTreeMap::new(); + for group in rsp + .get(1) + .and_then(|s| s.get("testGroups")) + .and_then(Value::as_array) + .expect("response testGroups") + { + for test in group.get("tests").and_then(Value::as_array).expect("response tests") { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + answers.insert(tc_id, test.clone()); + } + } + + let groups = req + .get(1) + .and_then(|s| s.get("testGroups")) + .and_then(Value::as_array) + .expect("request testGroups"); + + let mut encrypt_cases = 0usize; + let mut decrypt_pass_cases = 0usize; + let mut decrypt_fail_cases = 0usize; + let mut per_kind: BTreeMap = BTreeMap::new(); + + for group in groups { + let test_type = group.get("testType").and_then(Value::as_str).expect("testType"); + assert_eq!(test_type, "AFT", "this set is documented as AFT-only"); + let direction = group.get("direction").and_then(Value::as_str).expect("direction"); + let encrypt = match direction { + "encrypt" => true, + "decrypt" => false, + other => panic!("unexpected direction {other}"), + }; + let key_len = group.get("keyLen").and_then(Value::as_u64).expect("keyLen"); + let tag_len = group.get("tagLen").and_then(Value::as_u64).expect("tagLen"); + let iv_len = group.get("ivLen").and_then(Value::as_u64).expect("ivLen"); + let payload_len = group.get("payloadLen").and_then(Value::as_u64).expect("payloadLen"); + assert_eq!(iv_len, 96, "every group in this set has a 96-bit nonce"); + assert_eq!(tag_len % 8, 0, "tagLen must be a whole number of octets"); + + for test in group.get("tests").and_then(Value::as_array).expect("tests") { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + let answer = answers.get(&tc_id).unwrap_or_else(|| panic!("tcId {tc_id}: no answer")); + + let key_bytes = decode(test, "key", tc_id); + let nonce_bytes = decode(test, "iv", tc_id); + let nonce: [u8; NONCE_LEN] = nonce_bytes + .try_into() + .unwrap_or_else(|_| panic!("tcId {tc_id}: iv is not 12 bytes")); + let aad = decode(test, "aad", tc_id); + + // Input comes from the request, expected output from the response. + let input = decode(test, if encrypt { "pt" } else { "ct" }, tc_id); + + let expect_failure = answer + .get("testPassed") + .and_then(Value::as_bool) + .map(|passed| !passed) + .unwrap_or(false); + + let got = run_case(tc_id, key_len, tag_len, encrypt, &key_bytes, &nonce, &aad, &input); + + if encrypt { + assert!(!expect_failure, "tcId {tc_id}: an encrypt case cannot be a failure case"); + let expected = decode(answer, "ct", tc_id); + assert_eq!( + expected.len() as u64, + (payload_len + tag_len) / 8, + "tcId {tc_id}: the answer must be ciphertext || tag" + ); + let got = got.expect("an encrypt case never reports a tag failure"); + assert_eq!(got, expected, "tcId {tc_id}: AES-{key_len} CCM encrypt"); + encrypt_cases += 1; + } else if expect_failure { + assert!( + got.is_err(), + "tcId {tc_id}: the vectors say this ciphertext is inauthentic, \ + but decryption returned a payload" + ); + decrypt_fail_cases += 1; + } else { + let expected = decode(answer, "pt", tc_id); + let got = got.unwrap_or_else(|()| { + panic!("tcId {tc_id}: an authentic ACVP case failed its tag check") + }); + assert_eq!(got, expected, "tcId {tc_id}: AES-{key_len} CCM decrypt"); + decrypt_pass_cases += 1; + } + + *per_kind.entry(format!("AES-{key_len} t={} {direction}", tag_len / 8)).or_default() += + 1; + } + } + + println!("ACVP AES-CCM cases by parameter set:"); + for (kind, count) in &per_kind { + println!(" {kind}: {count}"); + } + println!( + " totals: {encrypt_cases} encrypt, {decrypt_pass_cases} decrypt-authentic, \ + {decrypt_fail_cases} decrypt-inauthentic" + ); + + // Guard against a silently-empty or partial run. These are the exact counts of the vector set, + // so a file that changed shape fails loudly instead of quietly testing less. + assert_eq!(encrypt_cases, 240, "expected 240 encrypt cases"); + assert_eq!(decrypt_pass_cases, 188, "expected 188 authentic decrypt cases"); + assert_eq!(decrypt_fail_cases, 52, "expected 52 inauthentic decrypt cases"); + assert_eq!( + encrypt_cases + decrypt_pass_cases + decrypt_fail_cases, + 480, + "every case in the set should be checked; none are skipped" + ); + // Three key lengths x two tag lengths x two directions: the full cross product, so every one + // of the six `run_case` instantiations is exercised in both directions. + assert_eq!( + per_kind.len(), + 12, + "expected all three key lengths at both tag lengths, in both directions" + ); +} diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs new file mode 100644 index 00000000..0ff69dfe --- /dev/null +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -0,0 +1,574 @@ +//! The four AES-CCM example vectors of NIST SP 800-38C Appendix C, and the streaming and +//! error-path properties that go with them. +//! +//! The vectors are transcribed from the errata-updated (07-20-2007) PDF of the recommendation. +//! Appendix C: "four examples are provided for the encryption-generation process of CCM with the +//! formatting and counter generation functions that are specified in Appendix A. The underlying +//! block cipher algorithm is the AES algorithm under a key of 128 bits." All four share one key +//! and differ in every length, which is what makes them worth having all four of: between them +//! they cover `t` of 4, 6, 8 and 14 and `q` of 8, 7, 3 and 2, i.e. both ends of each of A.1's +//! ranges. +//! +//! Appendix C prints `C` as a single string, which is Sec 6.1 step 8's +//! `(P XOR MSB_Plen(S)) || (T XOR MSB_Tlen(S0))` -- the ciphertext with the tag appended. It is +//! split here at `Plen`, and both layouts of the API are checked against the two halves. +//! +//! Appendix C gives no decryption examples ("From each example, a corresponding example of the +//! decryption-verification process of CCM is straightforward to construct"), so the decryption +//! direction is checked by round-tripping each vector's own `C` back to its `P`. + +use bouncycastle_aes::{AES_128, AES_192, AES_256}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_core_test_framework::symmetric_ciphers::TestFrameworkAEADCipher; +use bouncycastle_hex as hex; +use bouncycastle_modes::{Ccm, CcmDecryptor, CcmEncryptor, Decrypting, Encrypting}; + +/// Appendix C's key, the same in all four examples: `40414243 44454647 48494a4b 4c4d4e4f`. +const APPENDIX_C_KEY: &str = "404142434445464748494a4b4c4d4e4f"; + +fn key(hex_key: &str) -> KeyMaterial { + let bytes = hex::decode(hex_key).expect("valid hex key"); + assert_eq!(bytes.len(), N, "key length must match the parameter set"); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey) + .expect("a symmetric cipher key") +} + +/// [`SymmetricCipherError`] is deliberately not `PartialEq` -- it carries `&'static str` detail that +/// tests have no business pinning -- so these two match on the variant instead. +fn is_tag_failure(r: Result) -> bool { + matches!(r, Err(SymmetricCipherError::AEADTagCheckFailed)) +} + +fn buffer_len_error(r: Result) -> Option<(&'static str, usize)> { + match r { + Err(SymmetricCipherError::IncorrectOutputBufferLength(which, needed)) => { + Some((which, needed)) + } + _ => None, + } +} + +/// Drives one Appendix C example through every entry point, in both layouts and both directions. +/// +/// `c` is the appendix's whole `C` string; it is split at `plaintext.len()` into the ciphertext and +/// the tag, so a mistake in either half is caught, and so is a mistake in where the split belongs. +fn check_vector< + const KEY_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + P: bouncycastle_core::traits::ElectronicCodeBook, +>( + name: &str, + key_hex: &str, + nonce_hex: &str, + aad: &[u8], + plaintext_hex: &str, + c_hex: &str, +) { + type Enc = Ccm; + type Dec = Ccm; + + let k = key::(key_hex); + let nonce_bytes = hex::decode(nonce_hex).expect("valid hex nonce"); + let nonce: [u8; NONCE_LEN] = nonce_bytes.try_into().expect("nonce length matches NONCE_LEN"); + let plaintext = hex::decode(plaintext_hex).expect("valid hex plaintext"); + let c = hex::decode(c_hex).expect("valid hex C"); + + assert_eq!( + c.len(), + plaintext.len() + TAG_LEN, + "{name}: the appendix's C must be Plen + Tlen octets" + ); + let (want_ct, want_tag) = c.split_at(plaintext.len()); + + // --- Sec 6.1, detached tag --- + let mut ct = vec![0u8; plaintext.len()]; + let (written, tag) = Enc::::encrypt_detached( + &k, &nonce, aad, &plaintext, &mut ct, + ) + .expect("encryption"); + assert_eq!(written, plaintext.len(), "{name}: CCM never expands the payload"); + assert_eq!(ct, want_ct, "{name}: ciphertext"); + assert_eq!(tag, want_tag, "{name}: tag"); + + // --- Sec 6.1, the appendix's own inline `ciphertext || tag` layout --- + let mut inline = vec![0u8; plaintext.len() + TAG_LEN]; + let n = + Enc::::encrypt(&k, &nonce, aad, &plaintext, &mut inline) + .expect("encryption"); + assert_eq!(n, c.len(), "{name}: inline output length"); + assert_eq!(inline, c, "{name}: the whole C string of Appendix C"); + + // --- Sec 6.2, both layouts --- + let mut recovered = vec![0u8; plaintext.len()]; + let n = Dec::::decrypt_detached( + &k, + &nonce, + aad, + want_ct, + want_tag.try_into().expect("TAG_LEN bytes"), + &mut recovered, + ) + .expect("decryption"); + assert_eq!(n, plaintext.len()); + assert_eq!(recovered, plaintext, "{name}: detached round trip"); + + let mut recovered = vec![0u8; plaintext.len()]; + let n = Dec::::decrypt(&k, &nonce, aad, &c, &mut recovered) + .expect("decryption"); + assert_eq!(n, plaintext.len()); + assert_eq!(recovered, plaintext, "{name}: inline round trip"); + + // --- Every ciphertext chunking through the streaming API gives the same answer --- + // Sec 3 says CCM is not a streaming mode, and `Ccm` handles that by taking the payload length + // up front; given that, the chunking must be invisible, exactly as for the other modes. + for chunk in [1usize, 2, 3, 7, 16, 17] { + let mut ccm = Enc::::new(&k, &nonce, aad, plaintext.len()) + .expect("streaming init"); + let mut streamed = plaintext.clone(); + for piece in streamed.chunks_mut(chunk) { + ccm.do_encrypt_update(piece).expect("update"); + } + let streamed_tag = ccm.do_encrypt_final().expect("final"); + assert_eq!(streamed, want_ct, "{name}: ciphertext, streamed in {chunk}-byte chunks"); + assert_eq!(streamed_tag, want_tag, "{name}: tag, streamed in {chunk}-byte chunks"); + + let mut ccm = Dec::::new(&k, &nonce, aad, plaintext.len()) + .expect("streaming init"); + for piece in streamed.chunks_mut(chunk) { + ccm.do_decrypt_update(piece).expect("update"); + } + ccm.do_decrypt_final(want_tag.try_into().expect("TAG_LEN bytes")).expect("tag check"); + assert_eq!(streamed, plaintext, "{name}: plaintext, streamed in {chunk}-byte chunks"); + } + + // --- Every bit of the tag is checked, and so is every byte of the ciphertext and the AAD --- + let tag_arr: &[u8; TAG_LEN] = want_tag.try_into().expect("TAG_LEN bytes"); + for i in 0..TAG_LEN { + let mut bad = *tag_arr; + bad[i] ^= 0x80; + let mut out = vec![0u8; plaintext.len()]; + assert!( + is_tag_failure(Dec::::decrypt_detached( + &k, &nonce, aad, want_ct, &bad, &mut out + )), + "{name}: a flipped bit in tag byte {i} must be caught" + ); + assert!( + out.iter().all(|b| *b == 0), + "{name}: Sec 6.2 -- the payload must not be revealed on INVALID" + ); + } + if !want_ct.is_empty() { + let mut bad_ct = want_ct.to_vec(); + bad_ct[0] ^= 0x01; + let mut out = vec![0u8; plaintext.len()]; + assert!( + is_tag_failure(Dec::::decrypt_detached( + &k, &nonce, aad, &bad_ct, tag_arr, &mut out + )), + "{name}: a modified ciphertext must be caught" + ); + } + if !aad.is_empty() { + let mut bad_aad = aad.to_vec(); + bad_aad[0] ^= 0x01; + let mut out = vec![0u8; plaintext.len()]; + assert!( + is_tag_failure(Dec::::decrypt_detached( + &k, &nonce, &bad_aad, want_ct, tag_arr, &mut out + )), + "{name}: CCM authenticates the AAD as well as the payload" + ); + } + // Truncating the AAD by one byte changes `a`, which A.2.2 encodes in front of it, so this must + // fail even though the remaining bytes are genuine. + if aad.len() > 1 { + let mut out = vec![0u8; plaintext.len()]; + assert!( + is_tag_failure(Dec::::decrypt_detached( + &k, + &nonce, + &aad[..aad.len() - 1], + want_ct, + tag_arr, + &mut out + )), + "{name}: the AAD length is authenticated, not just its contents" + ); + } + // A different nonce must fail too: it changes both `B0` and every counter block. + let mut bad_nonce = nonce; + bad_nonce[0] ^= 0x01; + let mut out = vec![0u8; plaintext.len()]; + assert!( + is_tag_failure(Dec::::decrypt_detached( + &k, &bad_nonce, aad, want_ct, tag_arr, &mut out + )), + "{name}: the nonce is authenticated" + ); +} + +/// Appendix C.1: `Klen = 128, Tlen = 32, Nlen = 56, Alen = 64, Plen = 32`. +/// +/// `n = 7`, so `q = 8`: the widest length field A.1 allows, and the shortest permitted tag. +#[test] +fn appendix_c1() { + check_vector::<16, 7, 4, AES_128>( + "C.1", + APPENDIX_C_KEY, + "10111213141516", + &hex::decode("0001020304050607").unwrap(), + "20212223", + // C: 7162015b 4dac255d + "7162015b4dac255d", + ); +} + +/// Appendix C.2: `Klen = 128, Tlen = 48, Nlen = 64, Alen = 128, Plen = 128`. +/// +/// `n = 8`, so `q = 7`. The payload is exactly one block, which is the case where A.2.3's +/// "minimum number of '0' bits, possibly none" is none. +#[test] +fn appendix_c2() { + check_vector::<16, 8, 6, AES_128>( + "C.2", + APPENDIX_C_KEY, + "1011121314151617", + &hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(), + "202122232425262728292a2b2c2d2e2f", + // C: d2a1f0e0 51ea5f62 081a7792 073d593d 1fc64fbf accd + "d2a1f0e051ea5f62081a7792073d593d1fc64fbfaccd", + ); +} + +/// Appendix C.3: `Klen = 128, Tlen = 64, Nlen = 96, Alen = 160, Plen = 192`. +/// +/// `n = 12`, so `q = 3`. Both the AAD (20 bytes) and the payload (24 bytes) need zero-padding, and +/// the payload spans two counter blocks. +#[test] +fn appendix_c3() { + check_vector::<16, 12, 8, AES_128>( + "C.3", + APPENDIX_C_KEY, + "101112131415161718191a1b", + &hex::decode("000102030405060708090a0b0c0d0e0f10111213").unwrap(), + "202122232425262728292a2b2c2d2e2f3031323334353637", + // C: e3b201a9 f5b71a7a 9b1ceaec cd97e70b + // 6176aad9 a4428aa5 484392fb c1b09951 + "e3b201a9f5b71a7a9b1ceaeccd97e70b6176aad9a4428aa5484392fbc1b09951", + ); +} + +/// Appendix C.4: `Klen = 128, Tlen = 112, Nlen = 104, Alen = 524288, Plen = 256`. +/// +/// `n = 13`, so `q = 2`: the narrowest length field A.1 allows. This is the example that exercises +/// A.2.2's **six-octet** AAD length encoding, `0xff || 0xfe || [a]_32` -- `Alen` is 524288 bits, +/// i.e. `a = 65536`, which is past the `2^16 - 2^8` boundary. Nothing else in the appendix does, +/// and neither does the ACVP set, so this test is the only coverage of that branch against an +/// official answer. +/// +/// The appendix does not print `A` in full: "the given string of the first sixteen blocks of the +/// associated data string is concatenated with itself repeatedly to form a string of 524288 bits". +/// Those sixteen blocks are `00 01 02 ... ff`, so `A` is that 256-byte run repeated 256 times. +#[test] +fn appendix_c4() { + let mut aad = Vec::with_capacity(65536); + for _ in 0..256 { + aad.extend(0u8..=255u8); + } + assert_eq!(aad.len(), 65536, "Alen = 524288 bits"); + + check_vector::<16, 13, 14, AES_128>( + "C.4", + APPENDIX_C_KEY, + "101112131415161718191a1b1c", + &aad, + "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + // C: 69915dad 1e84c637 6a68c296 7e4dab61 + // 5ae0fd1f aec44cc4 84828529 463ccf72 + // b4ac6bec 93e8598e 7f0dadbc ea5b + "69915dad1e84c6376a68c2967e4dab615ae0fd1faec44cc484828529463ccf72\ + b4ac6bec93e8598e7f0dadbcea5b", + ); +} + +/// An empty payload and an empty AAD, which Appendix C never shows but Sec 5.3 explicitly permits: +/// "A may be the empty string", and its footnote, "The payload may also be empty, in which case +/// the specification degenerates to an authentication mode on the associated data". +/// +/// With `a = 0` and `p = 0` the formatted string is `B0` alone, so `r = 0` and the MAC is +/// `MSB_Tlen(Y0)`. There is no official vector for it; what is checked here is that all four +/// combinations of empty/non-empty are accepted, give distinct tags, and round-trip. +#[test] +fn empty_payload_and_empty_aad_are_permitted() { + type Enc = Ccm; + type Dec = Ccm; + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0x42u8; 12]; + let aad = b"header"; + let payload = b"payload"; + + let mut tags = Vec::new(); + for (a, p) in + [(&[][..], &[][..]), (&aad[..], &[][..]), (&[][..], &payload[..]), (&aad[..], &payload[..])] + { + let mut ct = vec![0u8; p.len()]; + let (written, tag) = Enc::encrypt_detached(&k, &nonce, a, p, &mut ct).expect("encryption"); + assert_eq!(written, p.len()); + + let mut back = vec![0u8; p.len()]; + let n = Dec::decrypt_detached(&k, &nonce, a, &ct, &tag, &mut back).expect("decryption"); + assert_eq!(n, p.len()); + assert_eq!(back, p, "round trip with aad {} / payload {}", a.len(), p.len()); + tags.push(tag); + } + + // An empty AAD must not be treated as the same message as a present one, nor an empty payload + // as the same as a present one: A.2.1's Adata bit and A.2.1's `Q` respectively make them + // distinct inputs to the MAC. + for i in 0..tags.len() { + for j in i + 1..tags.len() { + assert_ne!(tags[i], tags[j], "tags {i} and {j} must differ"); + } + } +} + +/// The whole [`AEADCipherEncryptor`] / [`AEADCipherDecryptor`] contract, through the shared +/// framework, for the buffering [`CcmEncryptor`] / [`CcmDecryptor`] pair. +/// +/// `BUFFER_LEN` is 256, comfortably above the longest message the suite tries +/// (`3 * TAG_LEN + 5 = 53`), and is also this pair's `FINAL_LEN`, since everything is flushed at +/// finalization. +#[test] +fn framework_streaming_contract() { + TestFrameworkAEADCipher::new().test_encryptor_decryptor::< + 16, + 12, + 16, + 256, + CcmEncryptor, + CcmDecryptor, + >(); +} + +/// The same, for the other two AES key lengths and a short tag, so the framework's error and +/// key-policy checks run against every parameterization the CLI and the aliases expose. +#[test] +fn framework_streaming_contract_other_parameter_sets() { + TestFrameworkAEADCipher::new().test_encryptor_decryptor::< + 24, + 12, + 16, + 256, + CcmEncryptor, + CcmDecryptor, + >(); + TestFrameworkAEADCipher::new().test_encryptor_decryptor::< + 32, + 12, + 16, + 256, + CcmEncryptor, + CcmDecryptor, + >(); + // A 13-byte nonce (q = 2) with an 8-byte tag: the parameterization IEEE 802.11 CCMP uses, and + // the one A.1's narrowest length field applies to. + TestFrameworkAEADCipher::new().test_encryptor_decryptor::< + 16, + 13, + 8, + 256, + CcmEncryptor, + CcmDecryptor, + >(); +} + +/// The buffering pair must agree with the non-buffering [`Ccm`] byte for byte -- they are two +/// routes to the same Sec 6.1 -- and it must be driven with a caller-chosen nonce to check that, +/// which is what `do_encrypt_init_rng` and a fixed-output RNG provide. +#[test] +fn the_buffering_pair_agrees_with_the_direct_api_on_appendix_c3() { + type Enc = CcmEncryptor; + type Dec = CcmDecryptor; + + let k = key::<16>(APPENDIX_C_KEY); + let nonce_bytes = hex::decode("101112131415161718191a1b").unwrap(); + let aad = hex::decode("000102030405060708090a0b0c0d0e0f10111213").unwrap(); + let plaintext = hex::decode("202122232425262728292a2b2c2d2e2f3031323334353637").unwrap(); + let c = + hex::decode("e3b201a9f5b71a7a9b1ceaeccd97e70b6176aad9a4428aa5484392fbc1b09951").unwrap(); + let (want_ct, want_tag) = c.split_at(plaintext.len()); + + // The trait generates the nonce; feed it Appendix C.3's so the answer is comparable, and check + // it came back, so an implementation that ignored the RNG could not pass silently. + let nonce_seed: [u8; 12] = nonce_bytes.clone().try_into().expect("12-byte nonce"); + let mut rng = FixedSeedRNG::<12>::new(nonce_seed); + let (mut enc, nonce) = Enc::do_encrypt_init_rng(&k, &mut rng).expect("init"); + assert_eq!(&nonce[..], &nonce_bytes[..], "the generated nonce must come from the RNG"); + + // Chunk both phases, and check `update_out_len`'s promise that nothing is released early. + enc.do_update_aad(&aad[..5]).expect("aad 1"); + enc.do_update_aad(&aad[5..]).expect("aad 2"); + let mut nothing = [0u8; 0]; + for piece in plaintext.chunks(7) { + assert_eq!(enc.update_out_len(piece.len()), 0, "CCM releases nothing mid-stream"); + assert_eq!(enc.do_update_out(piece, &mut nothing).expect("update"), 0); + } + let mut flushed = [0u8; 256]; + let (len, tag) = enc.do_encrypt_final(&mut flushed).expect("final"); + assert_eq!(len, plaintext.len(), "everything is flushed at finalization"); + assert_eq!(&flushed[..len], want_ct, "C.3 ciphertext via the trait"); + assert_eq!(&tag[..], want_tag, "C.3 tag via the trait"); + + let mut dec = Dec::do_decrypt_init(&k, &nonce).expect("init"); + dec.do_update_aad(&aad).expect("aad"); + for piece in want_ct.chunks(5) { + assert_eq!(dec.do_update_out(piece, &mut nothing).expect("update"), 0); + } + let mut out = [0u8; 256]; + let n = + dec.do_decrypt_final(want_tag.try_into().expect("8 bytes"), &mut out).expect("tag check"); + assert_eq!(&out[..n], &plaintext[..], "C.3 plaintext via the trait"); +} + +/// A message longer than `BUFFER_LEN` is refused rather than silently truncated, and so is an +/// oversized AAD. This is the cost of the trait's length-free `do_encrypt_init`; see +/// [`CcmEncryptor`]. +#[test] +fn the_buffering_pair_refuses_a_message_past_its_buffer() { + type Enc = CcmEncryptor; + let k = key::<16>(APPENDIX_C_KEY); + let mut nothing = [0u8; 0]; + + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert!(matches!( + enc.do_update_out(&[0u8; 33], &mut nothing), + Err(SymmetricCipherError::GenericError(_)) + )); + + // In two calls that together overflow, the first must succeed and the second be refused. + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert_eq!(enc.do_update_out(&[0u8; 20], &mut nothing).expect("fits"), 0); + assert!(matches!( + enc.do_update_out(&[0u8; 13], &mut nothing), + Err(SymmetricCipherError::GenericError(_)) + )); + + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert!(matches!(enc.do_update_aad(&[0u8; 33]), Err(SymmetricCipherError::GenericError(_)))); +} + +/// Sec 6.2 step 1: "If Clen <= Tlen, then return INVALID". The inline layout has to reject a `C` +/// too short to contain a tag before it can split one off. +/// +/// A `C` of exactly `TAG_LEN` octets is *not* too short: it is the empty payload of Sec 5.3's +/// footnote, and must authenticate. +#[test] +fn an_inline_ciphertext_shorter_than_the_tag_is_rejected() { + type Enc = Ccm; + type Dec = Ccm; + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0u8; 12]; + let mut out = [0u8; 16]; + + for len in 0..16 { + assert!( + matches!( + Dec::decrypt(&k, &nonce, &[], &vec![0u8; len], &mut out), + Err(SymmetricCipherError::GenericError(_)) + ), + "a {len}-byte C cannot carry a 16-byte tag" + ); + } + + // Exactly TAG_LEN: an empty payload plus its tag, which must verify. + let mut inline = [0u8; 16]; + let n = Enc::encrypt(&k, &nonce, &[], &[], &mut inline).expect("encryption"); + assert_eq!(n, 16); + assert_eq!(Dec::decrypt(&k, &nonce, &[], &inline, &mut out).expect("decryption"), 0); +} + +/// An output buffer that is too short is refused with the length required, before any work. +#[test] +fn undersized_output_buffers_are_refused() { + type Enc = Ccm; + type Dec = Ccm; + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0u8; 12]; + let plaintext = [0xAAu8; 24]; + + let mut too_small = [0u8; 23]; + assert_eq!( + buffer_len_error(Enc::encrypt_detached(&k, &nonce, &[], &plaintext, &mut too_small)), + Some(("ciphertext", 24)) + ); + + let mut too_small = [0u8; 39]; + assert_eq!( + buffer_len_error(Enc::encrypt(&k, &nonce, &[], &plaintext, &mut too_small)), + Some(("ciphertext", 40)) + ); + + let mut ct = [0u8; 40]; + Enc::encrypt(&k, &nonce, &[], &plaintext, &mut ct).expect("encryption"); + let mut too_small = [0u8; 23]; + assert_eq!( + buffer_len_error(Dec::decrypt(&k, &nonce, &[], &ct, &mut too_small)), + Some(("plaintext", 24)) + ); +} + +/// A key of the wrong [`KeyType`] is rejected by every entry point, in both directions. +#[test] +fn a_non_cipher_key_is_rejected() { + type Enc = Ccm; + type Dec = Ccm; + let wrong = + KeyMaterial::<16>::from_bytes_as_type(&[0x11; 16], KeyType::MACKey).expect("a MAC key"); + let mut out = [0u8; 16]; + assert!(matches!( + Enc::encrypt_detached(&wrong, &[0u8; 12], &[], &[], &mut out), + Err(SymmetricCipherError::KeyMaterialError(_)) + )); + assert!(matches!( + Enc::new(&wrong, &[0u8; 12], &[], 0), + Err(SymmetricCipherError::KeyMaterialError(_)) + )); + assert!(matches!( + Dec::decrypt(&wrong, &[0u8; 12], &[], &[0u8; 16], &mut out), + Err(SymmetricCipherError::KeyMaterialError(_)) + )); + assert!(matches!( + Dec::new(&wrong, &[0u8; 12], &[], 0), + Err(SymmetricCipherError::KeyMaterialError(_)) + )); +} + +/// The direction is in the type, so the wrong direction's method is a **compile** error rather +/// than a runtime one. This is what the `Dir` parameter buys over a runtime flag, and without a +/// test the guarantee could quietly regress into an inherent method on the shared impl block. +/// +/// Both of these are checked as `compile_fail` doctests on [`Ccm`] itself; this test is the +/// positive half -- that the *right* direction's methods do exist on each -- which a +/// `compile_fail` cannot express. +#[test] +fn each_direction_has_its_own_methods() { + type Enc = Ccm; + type Dec = Ccm; + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0x55u8; 12]; + + let mut enc = Enc::new(&k, &nonce, b"aad", 4).expect("encrypt init"); + let mut data = [1u8, 2, 3, 4]; + enc.do_encrypt_update(&mut data).expect("encrypt update"); + let tag = enc.do_encrypt_final().expect("encrypt final"); + + let mut dec = Dec::new(&k, &nonce, b"aad", 4).expect("decrypt init"); + dec.do_decrypt_update(&mut data).expect("decrypt update"); + dec.do_decrypt_final(&tag).expect("decrypt final"); + assert_eq!(data, [1u8, 2, 3, 4]); +} diff --git a/mem_usage_benches/Cargo.toml b/mem_usage_benches/Cargo.toml index ae00b642..f6b2cf7f 100644 --- a/mem_usage_benches/Cargo.toml +++ b/mem_usage_benches/Cargo.toml @@ -22,3 +22,7 @@ path = "src/bench_sha3_mem_usage.rs" [[bin]] name = "bench_aes_mem_usage" path = "src/bench_aes_mem_usage.rs" + +[[bin]] +name = "bench_ccm_mem_usage" +path = "src/bench_ccm_mem_usage.rs" diff --git a/mem_usage_benches/src/bench_ccm_mem_usage.rs b/mem_usage_benches/src/bench_ccm_mem_usage.rs new file mode 100644 index 00000000..1e401664 --- /dev/null +++ b/mem_usage_benches/src/bench_ccm_mem_usage.rs @@ -0,0 +1,189 @@ +//! The purpose of this binary is to perform a single run of the primitive under test so that +//! its peak memory usage can be measured with: +//! +//! ```text +//! valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_ccm_mem_usage > /dev/null +//! +//! ms_print massif.out.835000 +//! ``` +//! +//! or, shoved all into one line: +//! +//! ```text +//! clear; clear; valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_ccm_mem_usage > /dev/null; ms_print massif.out.*; rm massif.out.* +//! ``` +//! +//! Make sure you build in release mode! +//! +//! Note: print!() is used to force the compiler not to optimize away the actual code. +//! The important stuff for benchmarking goes to stderr so the junk can be piped to /dev/null. +//! +//! Main is at the bottom, and controls which of these actually runs -- measure one at a time, +//! because massif reports the peak across the whole process. +//! +//! # Why CCM gets a harness when the other modes do not +//! +//! CCM (NIST SP 800-38C) is the only mode in `bouncycastle-modes` with a non-trivial stack +//! profile, and it has it for a specific, avoidable reason. +//! +//! `Ccm` itself is boring: 256 B for AES-128, independent of message length, nonce length and tag +//! length, and per-byte work that touches a constant amount of stack. `print_struct_sizes` records +//! those, and they are the numbers to use. +//! +//! **`CcmEncryptor` / `CcmDecryptor` are the interesting case.** They exist to satisfy +//! `AEADCipherEncryptor` / `AEADCipherDecryptor`, whose `do_encrypt_init` is handed a key and no +//! length; CCM cannot form `B0` -- and so cannot authenticate anything -- until it knows the total +//! payload length (SP 800-38C Appendix A.2.1), so they buffer the whole message. That costs +//! `2 * BUFFER_LEN` in the value, and the trait's provided one-shots put a third `FINAL_LEN`-byte +//! buffer on the stack, so a call to `encrypt_out` is expected to peak at roughly +//! **`3 * BUFFER_LEN`**. That figure is quoted in the crate docs; `bench_buffering_encrypt_out` is +//! what checks it, since it is the one memory claim in that crate large enough to matter. +//! +//! The comparison to draw is `bench_buffering_encrypt_out` against +//! `bench_direct_encrypt_detached` on the *same* message: the direct path does identical cipher +//! work with none of the buffers, so the difference is the whole cost of using the generic trait. + +#![allow(dead_code)] +#![allow(unused_imports)] + +use bouncycastle::aes::{AES_128, AES_192, AES_256}; +use bouncycastle::core::key_material::{KeyMaterial, KeyType}; +use bouncycastle::core::traits::{AEADCipherDecryptor, AEADCipherEncryptor}; +use bouncycastle::modes::{Ccm, CcmDecryptor, CcmEncryptor, Decrypting, Encrypting}; + +/// The parameters the ACVP vectors and most protocols use: 12-byte nonce, 16-byte tag. +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; + +/// 4 KiB: comfortably above an 802.11 frame, the packet size CCM was designed for, and small +/// enough that `3 * BUFFER_LEN` is a sane amount of stack. +const BUFFER_LEN: usize = 4096; + +type Aes128Ccm

= Ccm; +type Aes128CcmEncryptor = CcmEncryptor; +type Aes128CcmDecryptor = CcmDecryptor; + +fn key() -> KeyMaterial { + KeyMaterial::::from_bytes_as_type(&[0x42u8; N], KeyType::SymmetricCipherKey).unwrap() +} + +/// This exists so /usr/bin/time can measure the base memory footprint of the harness itself. +fn bench_do_nothing() { + eprintln!("DoNothing"); + + print!("{}", 1 + 1); +} + +/// Prints the in-memory size of each CCM value: the persistent cost of holding one open. +/// +/// The two things to notice are that `Ccm` does not depend on `NONCE_LEN` or `TAG_LEN` -- the nonce +/// lives inside the counter template and the tag is assembled at finalization -- and that the +/// buffering pair is more than an order of magnitude larger at any useful `BUFFER_LEN`. +fn print_struct_sizes() { + use core::mem::size_of; + + eprintln!("--- Ccm: permutation + 3 blocks + 4 counters, independent of nonce/tag length ---"); + eprintln!("Ccm {:>7} B", size_of::>()); + eprintln!( + "Ccm {:>7} B", + size_of::>() + ); + eprintln!( + "Ccm {:>7} B", + size_of::>() + ); + eprintln!( + "Ccm {:>7} B", + size_of::>() + ); + eprintln!( + "Ccm {:>7} B", + size_of::>() + ); + eprintln!("Decrypting is the same size:"); + eprintln!("Ccm {:>7} B", size_of::>()); + + eprintln!("--- the buffering trait adapters: 2 * BUFFER_LEN each ---"); + eprintln!("CcmEncryptor<.., 4096> {:>7} B", size_of::()); + eprintln!("CcmDecryptor<.., 4096> {:>7} B", size_of::()); + eprintln!( + "CcmEncryptor<.., 256> {:>7} B", + size_of::>() + ); + + print!("{}", size_of::>()); +} + +/// The direct, non-buffering path over a 4 KiB message: `Ccm` plus the caller's own buffers, and +/// nothing else. This is the baseline for `bench_buffering_encrypt_out`. +fn bench_direct_encrypt_detached() { + eprintln!("Ccm::encrypt_detached, 4 KiB"); + + let k = key::<16>(); + let nonce = [0x24u8; NONCE_LEN]; + let plaintext = [0xA5u8; BUFFER_LEN]; + let mut ciphertext = [0u8; BUFFER_LEN]; + let (_, tag) = + Aes128Ccm::::encrypt_detached(&k, &nonce, &[], &plaintext, &mut ciphertext) + .unwrap(); + print!("{:x?}", &tag); +} + +/// The same 4 KiB message through the buffering `AEADCipherEncryptor` one-shot. +/// +/// Expected to peak at roughly `3 * BUFFER_LEN` above `bench_direct_encrypt_detached`: the +/// encryptor's own two buffers plus the `FINAL_LEN`-byte flush buffer that the trait's provided +/// `encrypt_out` puts on the stack. +fn bench_buffering_encrypt_out() { + eprintln!("CcmEncryptor::encrypt_out, 4 KiB"); + + let k = key::<16>(); + let plaintext = [0xA5u8; BUFFER_LEN]; + let mut ciphertext = [0u8; BUFFER_LEN]; + let (_, _, tag) = + Aes128CcmEncryptor::encrypt_out(&k, &[], &plaintext, &mut ciphertext).unwrap(); + print!("{:x?}", &tag); +} + +/// The decrypting side of the same comparison; `do_decrypt_final` also decrypts into the caller's +/// `FINAL_LEN` buffer before checking the tag. +fn bench_buffering_decrypt_out() { + eprintln!("CcmDecryptor::decrypt_out, 4 KiB"); + + let k = key::<16>(); + let plaintext = [0xA5u8; BUFFER_LEN]; + let mut ciphertext = [0u8; BUFFER_LEN]; + let (nonce, _, tag) = + Aes128CcmEncryptor::encrypt_out(&k, &[], &plaintext, &mut ciphertext).unwrap(); + + let mut recovered = [0u8; BUFFER_LEN]; + let n = Aes128CcmDecryptor::decrypt_out(&k, &nonce, &[], &ciphertext, &tag, &mut recovered) + .unwrap(); + print!("{n}"); +} + +/// The streaming direct path, which is what a caller in SP 800-38C Sec 3's packet environment +/// should use: the payload length is declared up front and nothing is buffered, so peak stack is +/// the `Ccm` value plus one chunk. +fn bench_direct_streaming() { + eprintln!("Ccm::do_encrypt_update, 4 KiB in 1 KiB chunks"); + + let k = key::<16>(); + let nonce = [0x24u8; NONCE_LEN]; + let mut data = [0xA5u8; BUFFER_LEN]; + let mut ccm = Aes128Ccm::::new(&k, &nonce, &[], data.len()).unwrap(); + for chunk in data.chunks_mut(1024) { + ccm.do_encrypt_update(chunk).unwrap(); + } + let tag = ccm.do_encrypt_final().unwrap(); + print!("{:x?}", &tag); +} + +fn main() { + print_struct_sizes() + // bench_do_nothing() + // bench_direct_encrypt_detached() + // bench_buffering_encrypt_out() + // bench_buffering_decrypt_out() + // bench_direct_streaming() +} diff --git a/mem_usage_benches/src/lib.rs b/mem_usage_benches/src/lib.rs index 0445bb89..54d20fc5 100644 --- a/mem_usage_benches/src/lib.rs +++ b/mem_usage_benches/src/lib.rs @@ -1,4 +1,5 @@ mod bench_aes_mem_usage; +mod bench_ccm_mem_usage; mod bench_mldsa_mem_usage; mod bench_mlkem_mem_usage; mod bench_sha3_mem_usage; From a1b2245e53676480e4fb659a0a4661f86c571864 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 14 Sep 2026 22:18:18 +0700 Subject: [PATCH 16/26] core, modes: document why AEADCipherEncryptor/Decryptor were not reshaped for CCM CCM was implemented in part to test whether the AEAD streaming traits could support a packet cipher; it confirmed they cannot without buffering, since SP 800-38C needs the total AAD and payload length before it can authenticate anything, and the trait's do_encrypt_init/do_update_aad/do_update_out are open-ended by design for the common case (Ascon-AEAD128, and GCM once it exists) that never needs a total up front. Record the finding and the chosen resolution -- buffer internally or ship a dedicated non-buffering API, not a length parameter on the shared trait -- at the trait definition itself, cross referenced from CcmEncryptor, so a future implementor doesn't have to re-derive it. --- crypto/core/src/traits.rs | 19 +++++++++++++++++++ crypto/modes/src/ccm.rs | 3 +++ 2 files changed, 22 insertions(+) diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 7bac7394..7be2eed2 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -307,6 +307,25 @@ pub trait AEADCipherDecryptor< /// everything released, in any chunking, plus the data part of /// [`do_encrypt_final`](Self::do_encrypt_final), is the ciphertext. /// +/// # A length-dependent construction still has to buffer +/// +/// [`do_encrypt_init`](Self::do_encrypt_init) takes no length, and [`do_update_aad`](Self::do_update_aad) +/// / [`do_update_out`](Self::do_update_out) are open-ended by design -- most AEAD constructions never +/// need to know a total in advance. Ascon-AEAD128 does not; GCM, once it exists in this crate, will not +/// either, because its length block is computed from tallied byte counts at finalization, not up front. +/// +/// CCM (NIST SP 800-38C) is the exception, and this trait was partly implemented for CCM specifically +/// to find out whether it was: Appendix A.2.1 puts the payload's octet length inside `B0`, the very +/// first block the CBC-MAC absorbs, and Appendix A.2.2's AAD length encoding must precede the AAD bytes +/// it describes, so neither AAD nor payload can be authenticated until the caller has finished handing +/// over the total of each. A construction with that property has exactly two options, and changing the +/// shape of this trait for one implementor's benefit is neither of them: buffer the whole message +/// internally and pay the memory cost (see `bouncycastle_modes::CcmEncryptor` / `CcmDecryptor`), or, +/// preferably when the caller can supply the lengths up front -- which a packet-oriented protocol +/// generally can -- provide a separate, purpose-built non-buffering API instead (see +/// `bouncycastle_modes::Ccm::new`). Do not add a length parameter here to spare one implementor a +/// buffer; every other implementor would carry a parameter it never uses. +/// /// # Any length, as a slice /// /// [`do_update_out`](Self::do_update_out)'s input is a `&[u8]` rather than a `&[u8; LEN]` because diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index fea57345..9e451dbc 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -879,6 +879,9 @@ where /// [`SymmetricCipherError::GenericError`]. Pick `BUFFER_LEN` from the largest packet the protocol /// allows -- CCM is a packet mode (Sec 3), so there is such a number. /// +/// See [`AEADCipherEncryptor`]'s "A length-dependent construction still has to buffer" section for +/// why this trait was not reshaped to avoid the buffering instead. +/// /// # Memory /// /// `2 * BUFFER_LEN` bytes in the value itself, plus the `FINAL_LEN`-byte buffer the trait's From 6a194ae7d48aefbb3b6e30c84417ac5cc88d1432 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:03:48 +0700 Subject: [PATCH 17/26] cli: --nonce-file for CCM reads raw bytes only, never hex-decodes read_from_file's hex-or-raw heuristic is fine for a key, where a wrong guess only produces a mismatch, but for a CCM nonce it can turn two distinct binary nonce files into the same nonce value if both happen to be valid hex text for it -- and a repeated nonce under one key breaks CCM's authentication (SP 800-38C Appendix B). Add read_from_file_raw and use it for --nonce-file specifically; --nonce (hex on the command line) is unaffected. PR #126 review, finding F1. --- cli/src/aes_ccm_cmd.rs | 9 ++++++-- cli/src/helpers.rs | 25 ++++++++++++++++++++++ cli/src/main.rs | 6 +++--- cli/tests/aes_ccm_cli_tests.rs | 38 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/cli/src/aes_ccm_cmd.rs b/cli/src/aes_ccm_cmd.rs index a28489a7..9d3aae30 100644 --- a/cli/src/aes_ccm_cmd.rs +++ b/cli/src/aes_ccm_cmd.rs @@ -120,13 +120,18 @@ pub(crate) fn aes256_ccm_cmd( ); } -/// Loads the nonce from `--nonce` (hex) or `--nonce-file` (hex or binary). +/// Loads the nonce from `--nonce` (hex) or `--nonce-file` (raw bytes, exactly as they are). /// /// Unlike the key there is no entropy question here: Sec 5.3 asks for uniqueness, not randomness, /// so an all-zero nonce is a perfectly valid *first* nonce and only a repeat is a problem. +/// +/// `--nonce-file` reads raw bytes ([`helpers::read_from_file_raw`]), not the hex-or-raw guess +/// [`helpers::read_from_file`] uses for keys: a repeated nonce under one key is fatal for CCM (see +/// the module docs), so two distinct binary nonce files that happen to look like hex text of the +/// same value must not silently collapse to the same nonce. fn load_nonce(nonce: &Option, nonce_file: &Option) -> Vec { let bytes = if let Some(file) = nonce_file { - helpers::read_from_file(file) + helpers::read_from_file_raw(file) } else if let Some(v) = nonce { hex::decode(v).unwrap_or_else(|_| { eprintln!("Error: nonce is not valid hex."); diff --git a/cli/src/helpers.rs b/cli/src/helpers.rs index fa476b04..0ef93c34 100644 --- a/cli/src/helpers.rs +++ b/cli/src/helpers.rs @@ -8,6 +8,31 @@ use std::io; use std::io::{Read, Write}; use std::process::exit; +/// Reads a file's bytes exactly as they are, with no hex-or-raw guessing. +/// +/// Use this where a misread would silently change the *value* the caller asked for rather than +/// merely fail to match it -- a nonce is the reason this exists: two distinct binary nonce files +/// that happen to decode as hex to the same bytes must not collapse to one nonce (see +/// `aes_ccm_cmd::load_nonce`). [`read_from_file`]'s "try hex, fall back to raw" heuristic is fine +/// for a key, where a wrong guess only ever produces a mismatch, never a same-looking-different +/// value. +pub(crate) fn read_from_file_raw(filename: &str) -> Vec { + let file = File::open(filename); + if file.is_ok() { + let mut buf = Vec::::new(); + match file.unwrap().read_to_end(&mut buf) { + Ok(_bytes_read) => buf, + Err(_) => { + eprintln!("Error: couldn't open file '{}'", &filename); + exit(-1); + } + } + } else { + eprintln!("Error: couldn't open file '{}'", &filename); + exit(-1); + } +} + /// Reads either bin or hex pub(crate) fn read_from_file(filename: &str) -> Vec { let file = File::open(&filename); diff --git a/cli/src/main.rs b/cli/src/main.rs index 6be91cb3..cd2d9c35 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1023,7 +1023,7 @@ enum Subcommands { #[arg(long)] nonce: Option, - /// A file containing the nonce, in hex or binary. + /// A file containing the nonce, as raw bytes exactly as they are (no hex decoding). #[arg(long)] nonce_file: Option, @@ -1061,7 +1061,7 @@ enum Subcommands { #[arg(long)] nonce: Option, - /// A file containing the nonce, in hex or binary. + /// A file containing the nonce, as raw bytes exactly as they are (no hex decoding). #[arg(long)] nonce_file: Option, @@ -1099,7 +1099,7 @@ enum Subcommands { #[arg(long)] nonce: Option, - /// A file containing the nonce, in hex or binary. + /// A file containing the nonce, as raw bytes exactly as they are (no hex decoding). #[arg(long)] nonce_file: Option, diff --git a/cli/tests/aes_ccm_cli_tests.rs b/cli/tests/aes_ccm_cli_tests.rs index ca0ca6de..ae423160 100644 --- a/cli/tests/aes_ccm_cli_tests.rs +++ b/cli/tests/aes_ccm_cli_tests.rs @@ -192,6 +192,44 @@ fn the_nonce_is_not_written_to_the_output_and_is_required_to_decrypt() { assert!(stderr.contains("authentication failed"), "got: {stderr}"); } +/// `--nonce-file` is raw bytes, not hex-or-raw guessed like `--key-file`: two different binary +/// nonces that happen to be valid hex *text* for the same value must not collapse to one nonce, +/// since a repeated nonce under one key breaks CCM's authentication (see the module docs). +#[test] +fn nonce_file_is_raw_bytes_not_hex_decoded() { + let dir = std::env::temp_dir().join(format!("bc_rust_ccm_cli_nonce_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + // 12 ASCII bytes that are also valid hex *text* -- decoding them halves the length to 6, which + // is out of CCM's 7..=13 range. A nonce-file that hex-decodes opportunistically would reject a + // perfectly good 12-byte nonce (or worse, silently accept a *different* file that decodes to + // the same 6 bytes); one that reads raw bytes only must accept these 12 bytes as-is. + let raw_path = dir.join("nonce_raw.bin"); + let raw_nonce = b"aabbccddeeff".to_vec(); + std::fs::write(&raw_path, &raw_nonce).expect("write raw nonce file"); + + let plaintext = b"the nonce file's bytes are used raw"; + let sealed = run_ok( + &["aes128-ccm", "encrypt", "--key", KEY_128, "--nonce-file", raw_path.to_str().unwrap()], + plaintext, + ); + + // Decrypting with the 12 raw bytes, passed directly via --nonce, must agree: --nonce-file did + // not hex-decode them down to 6 bytes. + let recovered = + run_ok(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", &hex(&raw_nonce)], &sealed); + assert_eq!(recovered, plaintext); + + // The would-be hex decoding of those same 12 ASCII bytes is only 6 bytes, out of CCM's + // 7..=13 range -- if --nonce-file had decoded them, this file would already have been + // rejected as a bad nonce length instead of round-tripping above. + let stderr = + run_err(&["aes128-ccm", "decrypt", "--key", KEY_128, "--nonce", "aabbccddeeff"], &sealed); + assert!(stderr.contains("nonce is 6 bytes"), "got: {stderr}"); + + std::fs::remove_dir_all(&dir).ok(); +} + /// Omitting the nonce is refused, and the message says why there is no generated one. #[test] fn a_missing_nonce_is_rejected_with_an_explanation() { From cd84a2c2aefaeb4fe443a8b99803bc5192ee979e Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:11:45 +0700 Subject: [PATCH 18/26] modes, core: zeroize CCM's CBC-MAC state, and make BUFFER_LEN vs the payload limit a compile error Ccm::y held Yr (the raw tag before the S0 mask) and every intermediate CBC-MAC chaining value in a plain array, unlike the keystream beside it, which is a Secret for the same reason; wrap it and finish_mac's local S0 the same way. Separately, CcmEncryptor/CcmDecryptor's BUFFER_LEN could exceed the payload limit NONCE_LEN implies (A.1's 2^8q - 1) and only fail at do_*_final, after buffering the whole message for nothing; assert the relationship at construction instead, which also makes MAX_PAYLOAD_LEN pub and lets do_*_final's # Errors sections state the guarantee precisely. Document the same capacity error as a general possibility on the trait's do_update_aad/do_update_out. PR #126 review, findings F3 and F4. --- crypto/core/src/traits.rs | 12 +++++-- crypto/modes/src/ccm.rs | 67 ++++++++++++++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 7be2eed2..bcd24e6b 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -81,7 +81,9 @@ pub trait AEADCipherDecryptor< /// # 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. + /// consumed in that case. As [`AEADCipherEncryptor::do_update_out`], an implementor with a + /// fixed buffering capacity may also return [`SymmetricCipherError::GenericError`] if + /// `ciphertext` would exceed it. fn do_update_out( &mut self, ciphertext: &[u8], @@ -374,6 +376,10 @@ pub trait AEADCipherEncryptor< /// # 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. + /// An implementor whose buffering has a fixed capacity -- see "A length-dependent construction + /// still has to buffer" above -- may also return [`SymmetricCipherError::GenericError`] if + /// `aad` would exceed it; that is a property of the implementor, not of this trait, so it is + /// not listed as a general contract here. 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 @@ -389,7 +395,9 @@ pub trait AEADCipherEncryptor< /// # 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. + /// consumed in that case. As [`do_update_aad`](Self::do_update_aad), an implementor with a + /// fixed buffering capacity may also return [`SymmetricCipherError::GenericError`] if + /// `plaintext` would exceed it. fn do_update_out( &mut self, plaintext: &[u8], diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index 9e451dbc..006d9af9 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -247,7 +247,11 @@ pub struct Ccm< // The CBC-MAC chaining value: `Y0` once the constructor has absorbed `B0` (Sec 6.1 step 2), // then `Yi` as further blocks arrive (step 3). Bytes are XORed into it in place, so part-way // through a block it holds `Yi-1 XOR (the part of Bi seen so far)`. - y: [u8; BLOCK_LEN], + // + // `Yr`'s low `TAG_LEN` bytes are the raw tag `T` before it is masked with `S0` (`finish_mac`), + // and every intermediate `Yi` is key-dependent CBC-MAC state, so this gets the same treatment + // as `ks` below rather than a plain array. + y: Secret<[u8; BLOCK_LEN]>, // How many bytes of the current CBC-MAC input block have been XORed into `y`. mac_pos: usize, // `Ctr_i` with its counter field zeroed (A.3, Table 3): the flags octet and the nonce, which @@ -286,8 +290,10 @@ where /// The largest payload this parameterization can carry, from A.1's "by definition, p<2^8q". /// /// `q = 8` would make `2^8q` exactly `2^64`, which does not fit a `u64`; there the bound is - /// `p <= 2^64 - 1`, i.e. `u64::MAX`, which is no bound at all on a `usize` length. - const MAX_PAYLOAD_LEN: u64 = + /// `p <= 2^64 - 1`, i.e. `u64::MAX`, which is no bound at all on a `usize` length. Public so a + /// caller choosing a `BUFFER_LEN` for [`CcmEncryptor`] / [`CcmDecryptor`], or reporting the + /// limit in an error message, has the real number instead of re-deriving it. + pub const MAX_PAYLOAD_LEN: u64 = if Self::Q_LEN >= 8 { u64::MAX } else { (1u64 << (8 * Self::Q_LEN)) - 1 }; /// The compile-time shape check, from Appendix A.1 and Sec 5.1; run from the constructor. @@ -405,7 +411,7 @@ where // Sec 6.1 step 2 is `Y0 = CIPH_K(B0)`, with no XOR, unlike step 3's `Bi XOR Yi-1`. // Starting the chaining value at zero unifies the two: `B0 XOR 0 = B0`, so absorbing // `B0` through the same path as every other block yields exactly `Y0`. - y: [0u8; BLOCK_LEN], + y: Secret::new(), mac_pos: 0, ctr_template, ks: Secret::new(), @@ -619,7 +625,10 @@ where // A.2.3: the payload's own blocks are zero-padded to a block boundary. self.mac_pad(); - let mut s0 = self.ctr_template; + // A keystream block of exactly the kind `ks` holds, so it gets the same `Secret` treatment + // rather than a plain local that outlives this function's stack frame unzeroed. + let mut s0: Secret<[u8; BLOCK_LEN]> = Secret::new(); + *s0 = self.ctr_template; Self::put_q_field(&mut s0, 0); self.perm.encrypt_block(&mut s0); @@ -879,6 +888,21 @@ where /// [`SymmetricCipherError::GenericError`]. Pick `BUFFER_LEN` from the largest packet the protocol /// allows -- CCM is a packet mode (Sec 3), so there is such a number. /// +/// A `BUFFER_LEN` past what `NONCE_LEN` allows (A.1's `2^8q - 1`) does not compile, rather than +/// buffering the whole message only to fail at [`do_encrypt_final`](AEADCipherEncryptor::do_encrypt_final): +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_core::traits::AEADCipherEncryptor; +/// use bouncycastle_modes::CcmEncryptor; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// // NONCE_LEN = 13 gives q = 2, a 65535-byte limit; BUFFER_LEN = 100_000 exceeds it. +/// let _ = CcmEncryptor::::do_encrypt_init(&key); +/// ``` +/// /// See [`AEADCipherEncryptor`]'s "A length-dependent construction still has to buffer" section for /// why this trait was not reshaped to avoid the buffering instead. /// @@ -955,6 +979,15 @@ where // The shape check belongs here too: this type never calls `Ccm::new`, and without it a // `NONCE_LEN` or `TAG_LEN` A.1 forbids would not be caught until `do_encrypt_final`. Ccm::::check_shape(); + const { + // Without this, a `BUFFER_LEN` beyond what `NONCE_LEN` allows compiles fine and only + // fails at `do_encrypt_final`, after the whole message has been buffered for nothing. + assert!( + BUFFER_LEN as u64 + <= Ccm::::MAX_PAYLOAD_LEN, + "CCM: BUFFER_LEN exceeds the payload limit 2^8q - 1 that NONCE_LEN implies (A.1)" + ); + }; let perm = Ccm::::checked_perm(key)?; let nonce = Ccm::::nonce_from_rng(rng)?; @@ -1029,6 +1062,13 @@ where /// Runs the whole of Sec 6.1 over the buffered message: writes the ciphertext to `output` and /// returns its length with the tag. + /// + /// # Errors + /// None, in practice: `do_encrypt_init_rng`'s `const` assertion already guarantees + /// `BUFFER_LEN <= `[`Ccm::MAX_PAYLOAD_LEN`]`, the only thing [`Ccm::new`]'s equivalent + /// construction path can fail on, and `do_update_out` already guarantees the AAD and payload + /// it buffered are each no more than `BUFFER_LEN`. The `Result` return exists to satisfy + /// [`AEADCipherEncryptor::do_encrypt_final`]'s signature. fn do_encrypt_final( mut self, output: &mut [u8; BUFFER_LEN], @@ -1108,6 +1148,15 @@ where nonce: &[u8; NONCE_LEN], ) -> Result { Ccm::::check_shape(); + const { + // See `CcmEncryptor::do_encrypt_init_rng`'s identical check: without it a `BUFFER_LEN` + // beyond what `NONCE_LEN` allows compiles fine and only fails at `do_decrypt_final`. + assert!( + BUFFER_LEN as u64 + <= Ccm::::MAX_PAYLOAD_LEN, + "CCM: BUFFER_LEN exceeds the payload limit 2^8q - 1 that NONCE_LEN implies (A.1)" + ); + }; let perm = Ccm::::checked_perm(key)?; Ok(Self { perm, @@ -1174,7 +1223,9 @@ where /// the MAC T shall not be revealed". /// /// # Errors - /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. + /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify. Nothing else: + /// `do_decrypt_init`'s `const` assertion already guarantees `BUFFER_LEN <= ` + /// [`Ccm::MAX_PAYLOAD_LEN`], the only other thing the construction this wraps can fail on. fn do_decrypt_final( mut self, tag: &[u8; TAG_LEN], @@ -1281,7 +1332,7 @@ mod tests { fn the_constructor_absorbs_b0() { let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; let ccm = Ccm::::new(&key(), &nonce, &[], 4).unwrap(); - assert_eq!(ccm.y, Ccm::::format_b0(&nonce, false, 4)); + assert_eq!(*ccm.y, Ccm::::format_b0(&nonce, false, 4)); assert_eq!(ccm.mac_pos, 0, "a whole block was absorbed, so nothing is part-filled"); } @@ -1424,7 +1475,7 @@ mod tests { let mut b1 = [0u8; 16]; b1[..2].copy_from_slice(&14u16.to_be_bytes()); let expected: [u8; 16] = core::array::from_fn(|i| b0[i] ^ b1[i]); - assert_eq!(ccm.y, expected, "y must be B0 ^ B1, with B1 starting with [14]_16"); + assert_eq!(*ccm.y, expected, "y must be B0 ^ B1, with B1 starting with [14]_16"); } /// A.1's `p < 2^8q`. With `n = 13`, `q = 2`, so the limit is 65535 and 65536 must be refused. From 99effdcfaeedcb48b2b69a074164d7c30b5dd7af Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:24:33 +0700 Subject: [PATCH 19/26] modes: batch CCM's CTR half, and fix docs that claimed it was impossible apply_keystream generated one counter block per encrypt_block call, even though A.3's Ctrj depends only on j and the counter blocks are exactly as independent as CTR's -- only the CBC-MAC half is genuinely serial (Sec 6.1 step 3). Restructure it like Ctr::apply: finish any open keystream block byte-wise, batch aligned whole blocks through encrypt_4blocks/encrypt_2blocks, then finish the tail byte-wise. Measured ~35-38% throughput gain (26->36 MiB/s for AES-128, no AAD; matches the buffering pair too), all 480 ACVP cases and 4 Appendix C vectors still pass. That made three doc passages actively wrong, since they said this was inherent: modes/src/lib.rs's mode comparison, modes_benches.rs's CCM doc comment (both rewritten with the new ratios against CTR), and lib.rs's "CCM takes no direction"/"there is no direction parameter" claims, which were already false against the code (Dir is very much a parameter) and predate this session. Also: fixed lib.rs's "264 B" vs the documented and now-tested 256 B, added size_of assertions pinning Ccm/CcmEncryptor's sizes against the memory table (previously undocumented by a test), and added the CCM aliases to the AES crate's "Modes of operation" section, which listed every other mode but this one. PR #126 review, findings F5 and F7. --- crypto/aes/src/lib.rs | 5 ++ crypto/modes/benches/modes_benches.rs | 38 ++++++------ crypto/modes/src/ccm.rs | 85 ++++++++++++++++++++++----- crypto/modes/src/lib.rs | 29 +++++---- crypto/modes/tests/sp800_38c_tests.rs | 37 ++++++++++++ 5 files changed, 148 insertions(+), 46 deletions(-) diff --git a/crypto/aes/src/lib.rs b/crypto/aes/src/lib.rs index 783141a4..8c56b4ed 100644 --- a/crypto/aes/src/lib.rs +++ b/crypto/aes/src/lib.rs @@ -72,6 +72,11 @@ //! [`AES_ECB_128`], [`AES_ECB_192`] and [`AES_ECB_256`] give ECB (Sec 6.1), which takes a padding //! scheme like CBC and has no IV, for interoperability and test vectors only -- see //! [A block permutation is not a cipher](#a-block-permutation-is-not-a-cipher). +//! [`AES_CCM_128`], [`AES_CCM_192`] and [`AES_CCM_256`] give CCM (SP 800-38C), this crate's only +//! *authenticated* mode: it takes the direction plus a nonce length and a tag length, both real +//! cryptographic choices rather than AES constants (see [`CCM_NONCE_LEN`], [`CCM_TAG_LEN`] for the +//! usual pair), and each has an `_Encryptor`/`_Decryptor` form for the generic AEAD traits. See the +//! `bouncycastle-modes` crate docs for why CCM is the mode to reach for in a new design. //! //! CBC is a block cipher, so it is defined only on whole blocks and the alias carries a padding //! scheme to bridge the difference; the CFB modes and CTR are stream ciphers and take any length diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs index 879c4787..7a2ce5c1 100644 --- a/crypto/modes/benches/modes_benches.rs +++ b/crypto/modes/benches/modes_benches.rs @@ -757,35 +757,36 @@ fn bench_init(c: &mut Criterion) { } /// CCM (SP 800-38C), which is the only authenticated mode here and the only one that costs -/// **two** cipher calls per block. +/// **two** cipher calls per block -- but only one of the two batches. /// /// Sec 5.2 builds CCM out of CTR for confidentiality and CBC-MAC for authenticity, over the same /// key, so every payload block goes through the forward cipher twice: once as a counter block and -/// once as a CBC-MAC input. The number to watch is CCM against the CTR group on the same data, and -/// **which** CTR number matters: +/// once as a CBC-MAC input. The CBC-MAC half is serial by construction (Sec 6.1 step 3: `Yi` is +/// the cipher of `Bi XOR Yi-1`), so unlike [`Ctr`] and the decrypt direction of `Cbc`/`Cfb` it has +/// no pair or four path -- but the CTR half has exactly `Ctr`'s parallelism (A.3's `Ctrj` depends +/// only on `j`), and `Ccm::apply_keystream` batches it the same way. So CCM sits *between* CTR's +/// two numbers, not at a fixed fraction of either: /// /// * against `modes::ctr::AES_128/16KiB encrypt -- N=1`, CTR's unbatched single-block path, CCM -/// should be **about half** -- two cipher calls per block instead of one, and nothing else; -/// * against CTR's `N=8` batched path, CCM should be about **a quarter**, because CCM cannot batch -/// at all and CTR's pair path roughly doubles it. +/// should be noticeably better than half -- one full unbatched pass (the MAC) plus a batched +/// pass that costs much less than a second unbatched one would; +/// * against CTR's `N=8` batched path, CCM should be noticeably better than a quarter, for the +/// same reason: only the MAC half pays the unbatched price. /// -/// Measured on the reference machine: 26 MiB/s for CCM against 51 MiB/s for CTR `N=1` and -/// 102 MiB/s for CTR `N=8`, i.e. both ratios as predicted. Materially worse than half of `N=1` -/// would mean something other than the two unavoidable cipher calls is dominating. -/// -/// Neither half of CCM can be batched, and that is inherent, not an omission. The CBC-MAC is serial -/// by construction (Sec 6.1 step 3: `Yi` is the cipher of `Bi XOR Yi-1`), so unlike `Ctr` and the -/// decrypt direction of `Cbc`/`Cfb` there is no pair or four path to take, and the counter blocks -/// are generated one at a time to stay interleaved with it. So CCM is deliberately absent from the -/// batch-path comparison the other groups are about. +/// Measured on the reference machine: 36 MiB/s for CCM against 52 MiB/s for CTR `N=1` (CCM at +/// ~69%, not ~50%) and 103 MiB/s for CTR `N=8` (CCM at ~35%, not ~25%) -- both above the naive +/// "two full unbatched passes" ratios, which is the batched CTR half showing up. /// /// Encryption and decryption should be within noise of each other: Sec 6.1 and Sec 6.2 do the same /// work in the opposite order (MAC-then-XOR versus XOR-then-MAC), and only the forward cipher is /// ever used, so the inverse cipher's cost never enters. /// /// The AAD is measured separately, and is the cheap half: it is absorbed into the CBC-MAC only, -/// one cipher call per block rather than two, so AAD-only throughput should be about twice the -/// payload's and about the same as CTR's. +/// one unbatched cipher call per block, against the payload's one unbatched call plus one batched +/// call. Batching the keystream narrows this gap from the naive "twice the payload's throughput" +/// to about **1.5x** -- measured 52 MiB/s AAD-only against 36 MiB/s for the payload -- and AAD-only +/// throughput should now sit close to CTR's *unbatched* number, since both are exactly one +/// unbatched cipher call per block. fn bench_ccm_aes128(c: &mut Criterion) { let key = key::<16>(); let nonce = [0x24u8; CCM_NONCE_LEN]; @@ -920,7 +921,8 @@ fn bench_ccm_buffering_pair(c: &mut Criterion) { }); // The same 4 KiB through `Ccm` directly, for the ratio. This one also draws no nonce, since - // `Ccm` takes it from the caller, so `bench_ccm_init` covers that difference separately. + // `Ccm` takes it from the caller -- the DRBG draw `CcmEncryptor::do_encrypt_init` pays for is + // not measured separately here; `bench_init` above times that same draw for the other modes. let nonce = [0x24u8; CCM_NONCE_LEN]; group.bench_function("Ccm::encrypt_detached 4KiB", |b| { b.iter_batched_ref( diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index 006d9af9..cd9b9840 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -569,39 +569,94 @@ where } } + /// Builds `Ctrj` (A.3, Table 3) for counter index `j`, without encrypting it. + #[inline] + fn counter_block(&self, j: u64) -> [u8; BLOCK_LEN] { + let mut ctr = self.ctr_template; + Self::put_q_field(&mut ctr, j); + ctr + } + /// Generates the next keystream block, `Sj = CIPH_K(Ctrj)` for the current `j` (Sec 6.1 /// steps 5-6), and advances `j`. #[inline] fn refill_keystream(&mut self) { - let mut ctr = self.ctr_template; - Self::put_q_field(&mut ctr, self.next_ctr); - *self.ks = ctr; + *self.ks = self.counter_block(self.next_ctr); self.perm.encrypt_block(&mut self.ks); self.next_ctr += 1; self.ks_pos = 0; } + /// XORs `data` (shorter than a block, or finishing/opening one) with the open keystream block, + /// refilling one block at a time as needed. Used for the bytes before and after the batched + /// whole-block run in [`Self::apply_keystream`]. + #[inline] + fn apply_keystream_bytes(&mut self, data: &mut [u8]) { + for byte in data.iter_mut() { + if self.ks_pos == BLOCK_LEN { + self.refill_keystream(); + } + *byte ^= self.ks[self.ks_pos]; + self.ks_pos += 1; + } + } + + /// XORs `N` whole blocks against `N` counter blocks encrypted in one batched call. + /// + /// `Ctrj` (A.3) depends only on `j`, not on the plaintext/ciphertext or on any other counter + /// block's cipher output, so the `N` forward ciphers here are independent -- the same + /// parallelism [`crate::Ctr`] uses, and unrelated to the CBC-MAC, which stays byte-at-a-time + /// serial (Sec 6.1 step 3: `Yi` depends on `Yi-1`) in [`Self::mac_absorb`]. Only the counter + /// half batches; nothing here changes what the MAC absorbs or when. + #[inline] + fn apply_keystream_batch( + &mut self, + blocks: &mut [[u8; BLOCK_LEN]; N], + batch: impl Fn(&P, &mut [[u8; BLOCK_LEN]; N]), + ) { + let mut ks = [[0u8; BLOCK_LEN]; N]; + for slot in ks.iter_mut() { + *slot = self.counter_block(self.next_ctr); + self.next_ctr += 1; + } + batch(&self.perm, &mut ks); + for (block, k) in blocks.iter_mut().zip(ks.iter()) { + for (b, k) in block.iter_mut().zip(k.iter()) { + *b ^= *k; + } + } + } + /// XORs `data` in place with the next `data.len()` bytes of `S1 || S2 || ...`. /// /// This is step 8's `P XOR MSB_Plen(S)` and Sec 6.2 step 5's `MSB(C) XOR MSB(S)` -- the same /// operation, which is why one function serves both directions. A call may start and end /// part-way through a keystream block, so the caller's chunking is invisible in the output, and /// only the tail of the very last block is ever discarded. + /// + /// Splits into the bytes that finish an already-open keystream block, the whole blocks that + /// follow, and the short tail, exactly as [`crate::Ctr::apply`] does; the middle goes through + /// the batch paths, only the two ends go byte by byte. #[inline] fn apply_keystream(&mut self, data: &mut [u8]) { - let mut rest = data; - while !rest.is_empty() { - if self.ks_pos == BLOCK_LEN { - self.refill_keystream(); - } - let take = core::cmp::min(BLOCK_LEN - self.ks_pos, rest.len()); - let (now, later) = rest.split_at_mut(take); - for (b, k) in now.iter_mut().zip(self.ks[self.ks_pos..].iter()) { - *b ^= *k; - } - self.ks_pos += take; - rest = later; + let head_len = if self.ks_pos < BLOCK_LEN { BLOCK_LEN - self.ks_pos } else { 0 }; + let (head, rest) = data.split_at_mut(core::cmp::min(head_len, data.len())); + self.apply_keystream_bytes(head); + + let (blocks, tail) = rest.as_chunks_mut::(); + let (fours, rest_blocks) = blocks.as_chunks_mut::<4>(); + for four in fours.iter_mut() { + self.apply_keystream_batch(four, P::encrypt_4blocks); + } + let (pairs, single) = rest_blocks.as_chunks_mut::<2>(); + for pair in pairs.iter_mut() { + self.apply_keystream_batch(pair, P::encrypt_2blocks); } + for block in single.iter_mut() { + self.apply_keystream_bytes(block); + } + + self.apply_keystream_bytes(tail); } /// Debits `len` bytes from the payload length declared to [`Self::new`]. diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs index c3de853f..1e49c59a 100644 --- a/crypto/modes/src/lib.rs +++ b/crypto/modes/src/lib.rs @@ -61,8 +61,8 @@ //! `AES_CBC_128` / `AES_CCM_128` / `AES_CFB_128` / `AES_CFB8_128` / `AES_CTR_128` / `AES_ECB_128` //! and friends from `bouncycastle-aes`. Those aliases are not all the same shape: the two block //! modes take a padding scheme as well as a direction, since neither is usable on data of arbitrary -//! length without one, the three stream modes take only the direction, and CCM takes no direction -//! at all but does take its nonce and tag lengths: +//! length without one, the three stream modes take only the direction, and CCM takes the direction +//! too, plus its nonce and tag lengths: //! //! ``` //! use bouncycastle_aes::{AES_128, AES_192, AES_256}; @@ -242,11 +242,11 @@ //! assert_eq!(data, plaintext); //! ``` //! -//! CCM is shaped differently from all of the above, because it is the only authenticated one. There -//! is no direction parameter, the nonce is supplied rather than generated, and there is an extra -//! input (the AAD, authenticated but not encrypted) and an extra output (the tag). Decryption -//! either returns the plaintext or fails -- it never returns plausible-looking rubbish the way the -//! unauthenticated modes do when the ciphertext has been altered: +//! CCM is shaped differently from all of the above, because it is the only authenticated one. The +//! nonce is supplied rather than generated, and there is an extra input (the AAD, authenticated but +//! not encrypted) and an extra output (the tag). Decryption either returns the plaintext or fails +//! -- it never returns plausible-looking rubbish the way the unauthenticated modes do when the +//! ciphertext has been altered: //! //! ``` //! use bouncycastle_aes::AES_128; @@ -302,10 +302,12 @@ //! bolting a MAC on afterwards is a design most people get wrong. CCM's costs, so that the choice //! is informed rather than reflexive: //! -//! * **Two cipher calls per block, and no batching.** CCM runs both CTR and a CBC-MAC over the same -//! data (Sec 5.2), and the CBC-MAC is serial, so it cannot use the permutation's pair or four -//! path. This crate's benches measure it at about half CTR's unbatched throughput and a quarter -//! of CTR's batched. +//! * **Two cipher calls per block, only one of which batches.** CCM runs both CTR and a CBC-MAC +//! over the same data (Sec 5.2). The CBC-MAC is serial by construction (Sec 6.1 step 3: `Yi` +//! depends on `Yi-1`), so it cannot use the permutation's pair or four path, but the CTR half +//! can and does, exactly as [`Ctr`] does. This crate's benches measure roughly two thirds of +//! CTR's unbatched throughput and a third of CTR's batched -- better than a naive "two full +//! passes" would suggest, because only one of the two passes pays the unbatched cost. //! * **It does not stream.** SP 800-38C Sec 3: "CCM is not designed to support partial processing //! or stream processing", because the payload length is inside the first block the MAC covers. //! `Ccm` handles that by taking the length up front, which costs nothing; code written against @@ -463,8 +465,9 @@ //! one memory figure in this crate worth thinking about before choosing an API. They buffer the //! whole message, so at `BUFFER_LEN = 2048` an AES-128 encryptor is **4304 B**, and the AEAD //! trait's one-shots put another `BUFFER_LEN` on the stack as the finalization buffer -- about -//! `3 * BUFFER_LEN` in total for a call to `encrypt_out`. Using [`Ccm`] directly costs 264 B -//! whatever the message length, and the benches measure no throughput difference between the two, +//! `3 * BUFFER_LEN` in total for a call to `encrypt_out`. Using [`Ccm`] directly costs 256 B for +//! AES-128 (the table above) whatever the message length, and the benches measure no throughput +//! difference between the two, //! so the buffering pair is worth it only when the generic trait is genuinely needed. See [`Ccm`] //! for why the buffering cannot be avoided in the trait. //! diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs index 0ff69dfe..4300288f 100644 --- a/crypto/modes/tests/sp800_38c_tests.rs +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -572,3 +572,40 @@ fn each_direction_has_its_own_methods() { dec.do_decrypt_final(&tag).expect("decrypt final"); assert_eq!(data, [1u8, 2, 3, 4]); } + +// ---- memory ------------------------------------------------------------------------------ + +/// Pins the "Memory Usage" table in the crate docs: `Ccm` is 256/288/320 B for AES-128/192/256, +/// independent of `NONCE_LEN`/`TAG_LEN`, and the buffering pair is `2 * BUFFER_LEN`. +#[test] +fn sizes_match_the_documented_memory_table() { + use core::mem::size_of; + + assert_eq!(size_of::>(), 256); + assert_eq!(size_of::>(), 288); + assert_eq!(size_of::>(), 320); + + // Independent of NONCE_LEN and TAG_LEN: the nonce lives inside the counter template and the + // tag is assembled at finalization, not held. + assert_eq!( + size_of::>(), + size_of::>() + ); + assert_eq!( + size_of::>(), + size_of::>() + ); + + // The direction marker is free, and does not change the layout. + assert_eq!( + size_of::>(), + size_of::>() + ); + + // The buffering adapters: 2 * BUFFER_LEN each (an `aad` array and a `data` array). + assert_eq!( + size_of::>(), + size_of::>() + ); + assert!(size_of::>() >= 2 * 4096); +} From 8b74a5ca21ba6c07d007ebefa12b560a8e5898c8 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:26:52 +0700 Subject: [PATCH 20/26] cli: stop BlockModeAction's shared help from describing behaviour CCM doesn't have The Encrypt/Decrypt value help (rendered by clap under --help for every mode subcommand, including the three CCM ones) said a fresh IV or nonce is generated and written to the output. CCM's nonce is supplied via --nonce and never written, so bc-rust aes128-ccm --help printed instructions that produce "authentication failed" if followed. Trim the shared enum's help to direction only and point at each subcommand's own --help, which already documents its mode's exact framing (CBC/CFB/CFB8/CTR already do; CCM's own help already explains the nonce is supplied, not generated). PR #126 review, finding F6. --- cli/src/block_mode_cmd.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/cli/src/block_mode_cmd.rs b/cli/src/block_mode_cmd.rs index ec4a7a87..b88269a2 100644 --- a/cli/src/block_mode_cmd.rs +++ b/cli/src/block_mode_cmd.rs @@ -66,19 +66,16 @@ pub(crate) const BLOCK_LEN: usize = 16; /// block at a time; it is bounded, so its cost does not scale with the input. pub(crate) const CHUNK_LEN: usize = 64 * BLOCK_LEN; -/// Which direction to run. Shared by every mode subcommand. +/// Which direction to run. Shared by every mode subcommand, including CCM's, whose framing (a +/// caller-supplied `--nonce` that is never written to the output, plus AAD and a tag) is +/// different enough from the rest that it is not summarized here -- see the specific subcommand's +/// own `--help` (`bc-rust aes128-ccm --help` and friends) for what `encrypt`/`decrypt` actually do +/// for the mode you are running. #[derive(ValueEnum, Clone, Debug)] pub(crate) enum BlockModeAction { - /// Encrypt stdin to stdout. - /// For CBC, CFB and CFB8 a freshly generated IV is written as the first 16 bytes of the - /// output, and for CTR a 12-byte nonce, so that `decrypt` can read it back; ECB has neither and - /// writes none. The `-cbc` and `-ecb` commands need the input to be a multiple of 16 bytes; - /// `-cfb`, `-cfb8` and `-ctr` take any length. See the individual subcommand's help. + /// Encrypt stdin to stdout. See the subcommand's own help for this mode's exact framing. Encrypt, - /// Decrypt stdin to stdout. - /// For CBC, CFB and CFB8 the first 16 bytes of input are taken as the IV, and for CTR the - /// first 12 as the nonce, as written by `encrypt`; ECB has neither and reads none. See - /// `encrypt` for the input-length rule. + /// Decrypt stdin to stdout. See the subcommand's own help for this mode's exact framing. Decrypt, } From 81a0cea74e07023accb0d86ab6d36cfb75bc3e93 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:31:14 +0700 Subject: [PATCH 21/26] cli: process CCM input in place instead of allocating a second buffer go() called the *_detached one-shots, each of which needs a fresh ciphertext/plaintext buffer the size of the input on top of the input buffer already read from stdin. Use Ccm::new plus do_*_update/do_*_final directly on the buffer already in hand: input.len() is exactly the declared payload length and is supplied in one call, so the two do_*_update/do_*_final calls this replaces cannot fail, which the .expect()s explain. Also: decrypt's tag split now goes through split_last_chunk_mut, matching Ccm::decrypt's own reasoning for admitting Clen == Tlen instead of restating the spec's stricter Clen <= Tlen and then testing < anyway; and the payload-limit error message reads Ccm::MAX_PAYLOAD_LEN (now pub) instead of re-deriving it. Documented the packet-AEAD exception to CLAUDE.md's CLI-streams rule this relies on. PR #126 review, finding F8 (buffer only; the pre-existing duplicated nonce-range check is deliberate and stays, per its own comment). --- CLAUDE.md | 6 ++- cli/src/aes_ccm_cmd.rs | 90 ++++++++++++++++++++++++++---------------- 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 247f7c8e..d09bbc41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,11 @@ Repo mechanics behind those rules, which the documents don't spell out: - `./dev_scripts/quality_stats.sh` produces the fallibility metrics both documents ask you to check. Run it before and after a change and compare, rather than eyeballing the diff. - **CLI commands stream.** The `cli/` binary is stdin→stdout with ~1 KB buffers so commands compose in shell - pipelines; preserve that when adding subcommands. + pipelines; preserve that when adding subcommands. The exception is a construction that is not + itself streamable, such as CCM (SP 800-38C Sec 3: "CCM is not designed to support partial + processing or stream processing", because the payload length is inside the first block the MAC + covers) -- there, read the whole input once and process it in place, rather than adding a second + buffer the size of the input on top of it; see `aes_ccm_cmd.rs`. - Trait → factory → CLI is the wiring path for a new primitive; see [the workspace architecture](#the-core--core-test-framework--factory-spine) above for the crates involved. ## Scope of changes diff --git a/cli/src/aes_ccm_cmd.rs b/cli/src/aes_ccm_cmd.rs index 9d3aae30..5bbad08b 100644 --- a/cli/src/aes_ccm_cmd.rs +++ b/cli/src/aes_ccm_cmd.rs @@ -198,25 +198,25 @@ fn run( ($n:literal) => { match tag_len { 4 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 6 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 8 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 10 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 12 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 14 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), 16 => go::( - key, &nonce_bytes, &aad_bytes, &input, encrypt, output_hex, + key, &nonce_bytes, &aad_bytes, input, encrypt, output_hex, ), other => { eprintln!( @@ -247,11 +247,16 @@ fn run( } /// One fully-instantiated CCM run. +/// +/// `input` is processed in place through [`Ccm`]'s own streaming API rather than through the +/// one-shot [`Ccm::encrypt`]/[`Ccm::decrypt`], which each need a second, freshly allocated buffer +/// the size of `input`: the declared-length constructor already has everything a one-shot needs, +/// so there is no second buffer to allocate or copy into. fn go( key: &KeyMaterial, nonce_bytes: &[u8], aad: &[u8], - input: &[u8], + mut input: Vec, encrypt: bool, output_hex: bool, ) where @@ -269,16 +274,22 @@ fn go( }; if encrypt { - let mut out = vec![0u8; input.len() + TAG_LEN]; - match Enc::::encrypt(key, &nonce, aad, input, &mut out) { - Ok(written) => { - helpers::write_bytes_or_hex(&out[..written], output_hex); + match Enc::::new(key, &nonce, aad, input.len()) { + Ok(mut ccm) => { + // `new` already accepted this exact length as `input.len()`, and this is the one + // and only call supplying it, so `take_owed` can never see too much and `owed` + // can never be left nonzero: neither of these can fail on the path that reaches + // them. + ccm.do_encrypt_update(&mut input).expect("declared length matches what was sent"); + let tag = ccm.do_encrypt_final().expect("declared length was fully supplied"); + helpers::write_bytes_or_hex(&input, output_hex); + helpers::write_bytes_or_hex(&tag, output_hex); if output_hex { println!(); } } Err(SymmetricCipherError::GenericError(msg)) => { - // The only `GenericError` reachable here is the payload limit: A.1's `p < 2^8q`, + // The only `GenericError` `new` can return is the payload limit: A.1's `p < 2^8q`, // where `q = 15 - n`. Report it with the numbers, since the fix is a shorter nonce. eprintln!("Error: {msg}"); eprintln!( @@ -286,7 +297,7 @@ fn go( limit is {} bytes.", input.len(), 15 - NONCE_LEN, - payload_limit(15 - NONCE_LEN), + Enc::::MAX_PAYLOAD_LEN, ); eprintln!(" Use a shorter nonce for a larger payload."); exit(-1) @@ -297,28 +308,43 @@ fn go( } } } else { - if input.len() < TAG_LEN { - // Sec 6.2 step 1: "If Clen <= Tlen, then return INVALID". + // `split_last_chunk_mut` is `None` exactly when there is no room for a `TAG_LEN`-byte tag, + // which is the same octet-level test (and the same allowance for an empty payload plus its + // tag) that `Ccm::decrypt`'s own doc comment explains for Sec 6.2 step 1. + let Some((data, tag)) = input.split_last_chunk_mut::() else { eprintln!( "Error: input is {} bytes, shorter than the {TAG_LEN}-byte tag it must end with.", input.len() ); exit(-1) - } - let mut out = vec![0u8; input.len() - TAG_LEN]; - match Dec::::decrypt(key, &nonce, aad, input, &mut out) { - Ok(written) => { - helpers::write_bytes_or_hex(&out[..written], output_hex); - if output_hex { - println!(); + }; + match Dec::::new(key, &nonce, aad, data.len()) { + Ok(mut ccm) => { + // As the encrypt arm above: `data.len()` is exactly the length just declared, and + // it is supplied in this one call, so this cannot fail. + ccm.do_decrypt_update(data).expect("declared length matches what was sent"); + match ccm.do_decrypt_final(tag) { + Ok(()) => { + helpers::write_bytes_or_hex(data, output_hex); + if output_hex { + println!(); + } + } + Err(SymmetricCipherError::AEADTagCheckFailed) => { + // Nothing has been written to stdout at this point, which is what + // processing in place still buys here: Sec 6.2's "the payload P and the + // MAC T shall not be revealed" holds end to end. + eprintln!( + "Error: AES-CCM authentication failed; the input is not authentic." + ); + exit(-1) + } + Err(e) => { + eprintln!("Error: AES-CCM decryption failed: {e:?}"); + exit(-1) + } } } - Err(SymmetricCipherError::AEADTagCheckFailed) => { - // Nothing has been written to stdout at this point, which is what buffering buys: - // Sec 6.2's "the payload P and the MAC T shall not be revealed" holds end to end. - eprintln!("Error: AES-CCM authentication failed; the input is not authentic."); - exit(-1) - } Err(e) => { eprintln!("Error: AES-CCM decryption failed: {e:?}"); exit(-1) @@ -326,9 +352,3 @@ fn go( } } } - -/// A.1's `2^8q - 1`, for the error message above. Saturates at `u64::MAX` for `q = 8`, where the -/// bound is beyond any real input anyway. -fn payload_limit(q: usize) -> u64 { - if q >= 8 { u64::MAX } else { (1u64 << (8 * q)) - 1 } -} From 395e955ee22e75431c2f45cb7f41e45f93ae44ae Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:44:46 +0700 Subject: [PATCH 22/26] modes: dedupe CcmEncryptor/CcmDecryptor over a shared CcmBuffer, drop the redundant key check CcmEncryptor and CcmDecryptor carried seven identical fields and byte-for-byte identical do_update_aad, differing only in one error string in do_update_out and in which Ccm direction do_*_final builds; the "set data_started before the length check" comment was on the encryptor's copy only. Factor the buffering itself into a private CcmBuffer that both now wrap as newtypes (the same pattern bouncycastle-ascon uses for AsconAead128Encryptor/Decryptor), so the shared behavior has one body. Also: Ccm::checked_perm re-checked KeyType::SymmetricCipherKey, which P::new (AES_128::new and friends) already checks per ElectronicCodeBook::new's own documented contract -- confirmed no other mode in this crate duplicates it, so it bought nothing but a second, differently-worded error message for the same bad key. Removed, and Ccm::new/CcmEncryptor/CcmDecryptor now call P::new(key) directly like every other mode. CcmEncryptor's nonce draw now calls crate::iv::random_iv, the same OS-backed draw Cbc/Cfb/Ctr already share, instead of a CCM-specific copy of the same three lines. No behavior or memory-layout change: CcmEncryptor/CcmDecryptor are still 8400 B at BUFFER_LEN=4096, all 480 ACVP cases and 4 Appendix C vectors still pass. PR #126 review, finding F10. --- crypto/modes/src/ccm.rs | 332 ++++++++++++++++++++++------------------ 1 file changed, 182 insertions(+), 150 deletions(-) diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index cd9b9840..99305323 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -152,8 +152,9 @@ //! [`AEADCipherDecryptor`]'s own warning that what `do_update_out` released is not authenticated //! until the final call returns `Ok`. -use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; -use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use crate::iv::random_iv; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::KeyMaterial; use bouncycastle_core::traits::{ AEADCipherDecryptor, AEADCipherEncryptor, Algorithm, ElectronicCodeBook, RNG, SecurityStrength, }; @@ -324,31 +325,6 @@ where }; } - /// Validates a [`KeyMaterial`] and expands it into the permutation's key schedule. - /// - /// The strength check is [`ElectronicCodeBook::new`]'s; this adds the [`KeyType`] check that - /// the trait leaves to the mode. - fn checked_perm(key: &KeyMaterial) -> Result { - if key.key_type() != KeyType::SymmetricCipherKey { - return Err( - KeyMaterialError::InvalidKeyType("CCM requires a SymmetricCipherKey").into() - ); - } - P::new(key) - } - - /// Draws a nonce from `rng`, for [`CcmEncryptor`]'s constructors. - /// - /// Sec 5.3 requires uniqueness, not randomness, but a CSPRNG draw is the only way to be unique - /// without state the trait's `do_encrypt_init` does not have. Every entry point that takes the - /// nonce from the caller instead is the better one where the caller can guarantee uniqueness - /// itself; see the module's security considerations. - fn nonce_from_rng(rng: &mut dyn RNG) -> Result<[u8; NONCE_LEN], SymmetricCipherError> { - let mut nonce = [0u8; NONCE_LEN]; - rng.next_bytes_out(&mut nonce)?; - Ok(nonce) - } - /// Begins a CCM flow: formats `B0`, absorbs it and all of `A` into the CBC-MAC, and readies the /// counter blocks. Everything after this streams without buffering. /// @@ -356,7 +332,8 @@ where /// payload length inside `B0` and A.2.2 puts the AAD length in front of the AAD: neither can be /// encoded incrementally. See the module docs. /// - /// * `key` must be a [`KeyType::SymmetricCipherKey`] of at least the permutation's strength. + /// * `key` must be a [`KeyType::SymmetricCipherKey`](bouncycastle_core::key_material::KeyType::SymmetricCipherKey) + /// of at least the permutation's strength. /// * `nonce` **must not** repeat under `key`; see the module's security considerations. /// * `aad` is authenticated but not encrypted, and may be empty. /// * `payload_len` is the exact number of payload bytes that will follow. Supplying any other @@ -374,8 +351,9 @@ where ) -> Result { // The shape check and the payload-limit check both belong to `from_perm`, which is the one // path every construction goes through; duplicating them here would be two more `Err` - // sites that could drift apart from it. - let perm = Self::checked_perm(key)?; + // sites that could drift apart from it. `P::new`'s own `KeyType`/strength checks are the + // only key validation needed, exactly as for every other mode in this crate. + let perm = P::new(key)?; Self::from_perm(perm, nonce, aad, payload_len) } @@ -968,12 +946,16 @@ where /// [`encrypt_out`](AEADCipherEncryptor::encrypt_out). The inherent [`Ccm`] API costs one block of /// each of chaining value, counter template and keystream regardless of message size, so **prefer /// it** unless you specifically need the trait. -pub struct CcmEncryptor< +/// Shared buffering state for [`CcmEncryptor`] / [`CcmDecryptor`]: everything Sec 6 needs before +/// it can run, factored out once because the two adapters need it in the identical shape (see +/// [`CcmEncryptor`] for why buffering is here at all). The direction-specific parts -- what the +/// buffered bytes are called, and which `Ccm` process finalization runs -- stay on the two +/// newtypes that wrap this. +struct CcmBuffer< P, const KEY_LEN: usize, const BLOCK_LEN: usize, const NONCE_LEN: usize, - const TAG_LEN: usize, const BUFFER_LEN: usize, > where P: ElectronicCodeBook, @@ -986,13 +968,140 @@ pub struct CcmEncryptor< // secret and is not wrapped. aad: [u8; BUFFER_LEN], aad_len: usize, - // The plaintext, held until finalization; wrapped so it is zeroized on drop. + // Plaintext for the encryptor, ciphertext for the decryptor; either way held until + // finalization, so wrapped so it is zeroized on drop. data: Secret<[u8; BUFFER_LEN]>, data_len: usize, // Set by the first `do_update_out`, which closes the AAD phase (see `do_update_aad`). data_started: bool, } +impl< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const BUFFER_LEN: usize, +> CcmBuffer +where + P: ElectronicCodeBook, +{ + fn new(perm: P, nonce: [u8; NONCE_LEN]) -> Self { + Self { + perm, + nonce, + aad: [0u8; BUFFER_LEN], + aad_len: 0, + data: Secret::new(), + data_len: 0, + data_started: false, + } + } + + /// Buffers `aad`. A sequence of calls is equivalent to one call over the concatenation, which + /// is what A.2.2 needs: the AAD is length-prefixed, so it can only be encoded once all of it + /// is in hand. + /// + /// # Errors + /// [`SymmetricCipherError::StateError`] for a non-empty `aad` after the first + /// `do_update_out`, and [`SymmetricCipherError::GenericError`] if the total would exceed + /// `BUFFER_LEN`. + fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { + if aad.is_empty() { + return Ok(()); + } + if self.data_started { + return Err(SymmetricCipherError::StateError("CCM: do_update_aad after do_update_out")); + } + let end = self.aad_len + aad.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError( + "CCM: associated data longer than BUFFER_LEN", + )); + } + self.aad[self.aad_len..end].copy_from_slice(aad); + self.aad_len = end; + Ok(()) + } + + /// Buffers `data` and writes nothing: nothing can be released before the payload length is + /// known, so the whole ciphertext or plaintext comes out at finalization. + /// + /// # Errors + /// [`SymmetricCipherError::GenericError`] if the total would exceed `BUFFER_LEN`. Nothing is + /// consumed in that case. + fn do_update_out(&mut self, data: &[u8]) -> Result<(), SymmetricCipherError> { + // Set before the length check so that a refused oversized call still closes the AAD phase: + // the phase order is about call history, and this call happened. + self.data_started = true; + let end = self.data_len + data.len(); + if end > BUFFER_LEN { + return Err(SymmetricCipherError::GenericError("CCM: data longer than BUFFER_LEN")); + } + self.data[self.data_len..end].copy_from_slice(data); + self.data_len = end; + Ok(()) + } + + /// Consumes the buffer, handing back everything [`Ccm::from_perm`] needs to run the real + /// process, plus the buffered data and its length. + fn into_parts( + self, + ) -> (P, [u8; NONCE_LEN], [u8; BUFFER_LEN], usize, Secret<[u8; BUFFER_LEN]>, usize) { + (self.perm, self.nonce, self.aad, self.aad_len, self.data, self.data_len) + } +} + +/// Adapts [`Ccm`] to [`AEADCipherEncryptor`] by buffering the whole message. +/// +/// [`AEADCipherEncryptor::do_encrypt_init`] is handed a key and nothing else, but CCM cannot form +/// `B0` -- and so cannot authenticate anything at all -- until it knows the total payload length +/// (Appendix A.2.1; see the module docs). This type therefore accumulates the AAD and the payload +/// in two `BUFFER_LEN`-byte arrays and runs the whole of Sec 6.1 in +/// [`do_encrypt_final`](AEADCipherEncryptor::do_encrypt_final), which is why `FINAL_LEN` is +/// `BUFFER_LEN`: every ciphertext byte is "flushed at finalization", and +/// [`update_out_len`](AEADCipherEncryptor::update_out_len) is identically `0`. +/// +/// A message or an AAD longer than `BUFFER_LEN` is refused with +/// [`SymmetricCipherError::GenericError`]. Pick `BUFFER_LEN` from the largest packet the protocol +/// allows -- CCM is a packet mode (Sec 3), so there is such a number. +/// +/// A `BUFFER_LEN` past what `NONCE_LEN` allows (A.1's `2^8q - 1`) does not compile, rather than +/// buffering the whole message only to fail at [`do_encrypt_final`](AEADCipherEncryptor::do_encrypt_final): +/// +/// ```compile_fail +/// use bouncycastle_aes::AES_128; +/// use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +/// use bouncycastle_core::traits::AEADCipherEncryptor; +/// use bouncycastle_modes::CcmEncryptor; +/// +/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +/// .unwrap(); +/// // NONCE_LEN = 13 gives q = 2, a 65535-byte limit; BUFFER_LEN = 100_000 exceeds it. +/// let _ = CcmEncryptor::::do_encrypt_init(&key); +/// ``` +/// +/// See [`AEADCipherEncryptor`]'s "A length-dependent construction still has to buffer" section for +/// why this trait was not reshaped to avoid the buffering instead. +/// +/// # Memory +/// +/// `2 * BUFFER_LEN` bytes in the value itself, plus the `FINAL_LEN`-byte buffer the trait's +/// provided one-shots put on the stack: about `3 * BUFFER_LEN` in total through +/// [`encrypt_out`](AEADCipherEncryptor::encrypt_out). The inherent [`Ccm`] API costs one block of +/// each of chaining value, counter template and keystream regardless of message size, so **prefer +/// it** unless you specifically need the trait. +pub struct CcmEncryptor< + P, + const KEY_LEN: usize, + const BLOCK_LEN: usize, + const NONCE_LEN: usize, + const TAG_LEN: usize, + const BUFFER_LEN: usize, +>(CcmBuffer) +where + P: ElectronicCodeBook; + impl< P, const KEY_LEN: usize, @@ -1043,21 +1152,13 @@ where "CCM: BUFFER_LEN exceeds the payload limit 2^8q - 1 that NONCE_LEN implies (A.1)" ); }; - let perm = Ccm::::checked_perm(key)?; - let nonce = - Ccm::::nonce_from_rng(rng)?; - Ok(( - Self { - perm, - nonce, - aad: [0u8; BUFFER_LEN], - aad_len: 0, - data: Secret::new(), - data_len: 0, - data_started: false, - }, - nonce, - )) + // `P::new`'s own checks are the only key validation needed, exactly as for `Ccm` itself + // and every other mode in this crate; `random_iv` is CBC/CFB's same OS-backed draw -- + // Sec 5.3 asks only for uniqueness, not CBC/CFB's unpredictability, but a CSPRNG draw is + // the only way to be unique without state `do_encrypt_init` does not have. + let perm = P::new(key)?; + let nonce = random_iv::(rng)?; + Ok((Self(CcmBuffer::new(perm, nonce)), nonce)) } /// Buffers `aad`. A sequence of calls is equivalent to one call over the concatenation, which @@ -1065,25 +1166,10 @@ where /// is in hand. /// /// # Errors - /// [`SymmetricCipherError::StateError`] for a non-empty `aad` after the first - /// `do_update_out`, and [`SymmetricCipherError::GenericError`] if the total would exceed - /// `BUFFER_LEN`. + /// `SymmetricCipherError::StateError` for a non-empty `aad` after the first `do_update_out`, + /// and `SymmetricCipherError::GenericError` if the total would exceed `BUFFER_LEN`. fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - if aad.is_empty() { - return Ok(()); - } - if self.data_started { - return Err(SymmetricCipherError::StateError("CCM: do_update_aad after do_update_out")); - } - let end = self.aad_len + aad.len(); - if end > BUFFER_LEN { - return Err(SymmetricCipherError::GenericError( - "CCM: associated data longer than BUFFER_LEN", - )); - } - self.aad[self.aad_len..end].copy_from_slice(aad); - self.aad_len = end; - Ok(()) + self.0.do_update_aad(aad) } /// Identically `0`: nothing can be released before the payload length is known, so the whole @@ -1093,25 +1179,13 @@ where } /// Buffers `plaintext` and writes nothing, per [`Self::update_out_len`]. `ciphertext` is - /// untouched and may be empty. - /// - /// # Errors - /// [`SymmetricCipherError::GenericError`] if the total would exceed `BUFFER_LEN`. Nothing is - /// consumed in that case. + /// untouched and may be empty. May return `SymmetricCipherError::GenericError` if the total would exceed `BUFFER_LEN`. fn do_update_out( &mut self, plaintext: &[u8], _ciphertext: &mut [u8], ) -> Result { - // Set before the length check so that a refused oversized call still closes the AAD phase: - // the phase order is about call history, and this call happened. - self.data_started = true; - let end = self.data_len + plaintext.len(); - if end > BUFFER_LEN { - return Err(SymmetricCipherError::GenericError("CCM: payload longer than BUFFER_LEN")); - } - self.data[self.data_len..end].copy_from_slice(plaintext); - self.data_len = end; + self.0.do_update_out(plaintext)?; Ok(0) } @@ -1125,24 +1199,22 @@ where /// it buffered are each no more than `BUFFER_LEN`. The `Result` return exists to satisfy /// [`AEADCipherEncryptor::do_encrypt_final`]'s signature. fn do_encrypt_final( - mut self, + self, output: &mut [u8; BUFFER_LEN], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { - let len = self.data_len; - // Move the schedule out rather than cloning it; `self` is consumed either way. `Secret`'s - // `Default` gives a zeroed placeholder, so nothing sensitive is left behind in `self.perm` - // -- `P` holds its own schedule in a `Secret` that is dropped with the `Ccm` below. + let (perm, nonce, aad, aad_len, mut data, len) = self.0.into_parts(); let mut ccm = Ccm::::from_perm( - self.perm, - &self.nonce, - &self.aad[..self.aad_len], + perm, + &nonce, + &aad[..aad_len], len, )?; - output[..len].copy_from_slice(&self.data[..len]); - // Scrub the plaintext copy as soon as the ciphertext is in `output`; `self` is dropped at - // the end of this call anyway, but the buffer is large and this keeps the window short. + output[..len].copy_from_slice(&data[..len]); + // Scrub the plaintext copy as soon as the ciphertext is in `output`, rather than waiting + // for `data` to drop at the end of this call: the buffer is large and this keeps the + // window short. ccm.do_encrypt_update(&mut output[..len])?; - self.data.zeroize(); + data.zeroize(); let tag = ccm.do_encrypt_final()?; Ok((len, tag)) } @@ -1157,19 +1229,9 @@ pub struct CcmDecryptor< const NONCE_LEN: usize, const TAG_LEN: usize, const BUFFER_LEN: usize, -> where - P: ElectronicCodeBook, -{ - perm: P, - nonce: [u8; NONCE_LEN], - aad: [u8; BUFFER_LEN], - aad_len: usize, - // Ciphertext rather than plaintext, so not secret in itself; wrapped anyway, because - // `do_decrypt_final` decrypts in place before the tag is checked. - data: Secret<[u8; BUFFER_LEN]>, - data_len: usize, - data_started: bool, -} +>(CcmBuffer) +where + P: ElectronicCodeBook; impl< P, @@ -1212,36 +1274,16 @@ where "CCM: BUFFER_LEN exceeds the payload limit 2^8q - 1 that NONCE_LEN implies (A.1)" ); }; - let perm = Ccm::::checked_perm(key)?; - Ok(Self { - perm, - nonce: *nonce, - aad: [0u8; BUFFER_LEN], - aad_len: 0, - data: Secret::new(), - data_len: 0, - data_started: false, - }) + // `P::new`'s own checks are the only key validation needed; see the encryptor's identical + // reasoning. + let perm = P::new(key)?; + Ok(Self(CcmBuffer::new(perm, *nonce))) } /// As [`CcmEncryptor::do_update_aad`](AEADCipherEncryptor::do_update_aad); the concatenation /// must match the encryptor's byte for byte or the tag check fails. fn do_update_aad(&mut self, aad: &[u8]) -> Result<(), SymmetricCipherError> { - if aad.is_empty() { - return Ok(()); - } - if self.data_started { - return Err(SymmetricCipherError::StateError("CCM: do_update_aad after do_update_out")); - } - let end = self.aad_len + aad.len(); - if end > BUFFER_LEN { - return Err(SymmetricCipherError::GenericError( - "CCM: associated data longer than BUFFER_LEN", - )); - } - self.aad[self.aad_len..end].copy_from_slice(aad); - self.aad_len = end; - Ok(()) + self.0.do_update_aad(aad) } /// Identically `0`. This is the one thing a CCM decryptor gets *right* by being forced to @@ -1251,24 +1293,14 @@ where 0 } - /// Buffers `ciphertext` and writes nothing, per [`Self::update_out_len`]. - /// - /// # Errors - /// [`SymmetricCipherError::GenericError`] if the total would exceed `BUFFER_LEN`. + /// Buffers `ciphertext` and writes nothing, per [`Self::update_out_len`]. May return + /// `SymmetricCipherError::GenericError` if the total would exceed `BUFFER_LEN`. fn do_update_out( &mut self, ciphertext: &[u8], _plaintext: &mut [u8], ) -> Result { - self.data_started = true; - let end = self.data_len + ciphertext.len(); - if end > BUFFER_LEN { - return Err(SymmetricCipherError::GenericError( - "CCM: ciphertext longer than BUFFER_LEN", - )); - } - self.data[self.data_len..end].copy_from_slice(ciphertext); - self.data_len = end; + self.0.do_update_out(ciphertext)?; Ok(0) } @@ -1282,20 +1314,20 @@ where /// `do_decrypt_init`'s `const` assertion already guarantees `BUFFER_LEN <= ` /// [`Ccm::MAX_PAYLOAD_LEN`], the only other thing the construction this wraps can fail on. fn do_decrypt_final( - mut self, + self, tag: &[u8; TAG_LEN], output: &mut [u8; BUFFER_LEN], ) -> Result { - let len = self.data_len; + let (perm, nonce, aad, aad_len, mut data, len) = self.0.into_parts(); let mut ccm = Ccm::::from_perm( - self.perm, - &self.nonce, - &self.aad[..self.aad_len], + perm, + &nonce, + &aad[..aad_len], len, )?; - output[..len].copy_from_slice(&self.data[..len]); + output[..len].copy_from_slice(&data[..len]); ccm.do_decrypt_update(&mut output[..len])?; - self.data.zeroize(); + data.zeroize(); match ccm.do_decrypt_final(tag) { Ok(()) => Ok(len), Err(e) => { From 35dfb038303dabee4e375149a8cd60b79bf7bc52 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 01:54:58 +0700 Subject: [PATCH 23/26] modes: add a Wycheproof AES-CCM suite, move/drop CCM unit tests that used no private API crypto/modes/tests/wycheproof_ccm_tests.rs drives bc-test-data's vendored aes_ccm_test.json (552 tests) through Ccm::encrypt_detached/decrypt_detached, following the file/skip-with-warning convention acvp_ccm_tests.rs already uses. Unlike the ACVP set (one nonce length, no malformed inputs), this one is deliberately adversarial: every nonce length from 8 to 2144 bits, tag sizes A.1 forbids, truncated and bit-flipped tags. Ccm's NONCE_LEN/TAG_LEN are const generics restricted to A.1's sets, so a case whose sizes fall outside them has no instantiation to dispatch to at all -- not a runtime failure, a compile-time non-option -- and those are counted as skipped rather than silently dropped. Locally: 486 of 552 cases run (405 valid, 81 invalid), 66 skipped across 63 out-of-range groups, all passing. bc-test-data/crypto/wycheproof/ already vendors sm4_ccm_test.json for this exact purpose; aes_ccm_test.json needs adding there too (copied from https://github.com/C2SP/wycheproof, testvectors_v1) for this suite to run anywhere but here -- that's a separate repository this PR cannot touch. Also, per QUALITY_AND_STYLE.md's unit-vs-integration-test rule (a unit test only where the behaviour cannot be reached from outside): moved payload_longer_than_the_q_limit_is_refused and a_short_or_long_payload_is_refused out of ccm.rs's #[cfg(test)] block into sp800_38c_tests.rs (converted from the toy Identity permutation to AES_128, matching that file's convention), since both exercise only Ccm::new/do_encrypt_update/do_encrypt_final. Deleted both_directions_mac_the_plaintext outright: it was byte-for-byte the same check as sp800_38c_tests.rs's each_direction_has_its_own_methods, just against Identity instead of AES_128. What remains in ccm.rs's own test module is exactly what its module doc says it should be: the private formatting helpers (format_b0, encode_aad_len, put_q_field) that no public API exposes directly. PR #126 review, finding F9. --- crypto/modes/src/ccm.rs | 69 ----- crypto/modes/tests/sp800_38c_tests.rs | 43 +++ crypto/modes/tests/wycheproof_ccm_tests.rs | 305 +++++++++++++++++++++ 3 files changed, 348 insertions(+), 69 deletions(-) create mode 100644 crypto/modes/tests/wycheproof_ccm_tests.rs diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index 99305323..e3861ea9 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -1564,73 +1564,4 @@ mod tests { let expected: [u8; 16] = core::array::from_fn(|i| b0[i] ^ b1[i]); assert_eq!(*ccm.y, expected, "y must be B0 ^ B1, with B1 starting with [14]_16"); } - - /// A.1's `p < 2^8q`. With `n = 13`, `q = 2`, so the limit is 65535 and 65536 must be refused. - #[test] - fn payload_longer_than_the_q_limit_is_refused() { - let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c]; - assert!( - Ccm::::new(&key(), &nonce, &[], 65535).is_ok(), - "2^16 - 1 is the largest payload q = 2 can encode" - ); - assert!( - matches!( - Ccm::::new(&key(), &nonce, &[], 65536), - Err(SymmetricCipherError::GenericError(_)) - ), - "2^16 does not fit [p]_16" - ); - } - - /// The declared payload length is inside `B0`, so neither direction may be finalized with the - /// wrong amount of data. - #[test] - fn a_short_or_long_payload_is_refused() { - let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; - let mut ccm = - Ccm::::new(&key(), &nonce, &[], 8).unwrap(); - let mut too_much = [0u8; 9]; - assert!( - matches!( - ccm.do_encrypt_update(&mut too_much), - Err(SymmetricCipherError::StateError(_)) - ), - "9 bytes against a declared 8" - ); - let mut some = [0u8; 4]; - ccm.do_encrypt_update(&mut some).expect("4 of the 8 declared bytes"); - assert!( - matches!(ccm.do_encrypt_final(), Err(SymmetricCipherError::StateError(_))), - "finalizing 4 bytes short" - ); - } - - /// The two directions absorb the *plaintext* into the CBC-MAC, in both cases: Sec 6.1 step 1 - /// formats `P` and Sec 6.2 step 7 formats the recovered `P`, never the ciphertext. So an - /// encryptor and a decryptor over the same message must reach the same `Yr`, and therefore the - /// same tag, even though they apply the keystream and the MAC in the opposite order. - /// - /// This is the property the wrong-direction runtime check used to guard; the `Dir` parameter - /// now makes the misuse a compile error (see the `compile_fail` examples on `Ccm`), so what is - /// left worth testing is that the two orders genuinely agree. - #[test] - fn both_directions_mac_the_plaintext() { - let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; - let plaintext = [0xDEu8, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; - - let mut enc = - Ccm::::new(&key(), &nonce, b"h", plaintext.len()) - .unwrap(); - let mut data = plaintext; - enc.do_encrypt_update(&mut data).unwrap(); - let tag = enc.do_encrypt_final().unwrap(); - - // The decryptor is handed the ciphertext, recovers the plaintext, and must agree on the tag. - let mut dec = - Ccm::::new(&key(), &nonce, b"h", plaintext.len()) - .unwrap(); - dec.do_decrypt_update(&mut data).unwrap(); - dec.do_decrypt_final(&tag).expect("the two directions must reach the same Yr"); - assert_eq!(data, plaintext); - } } diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs index 4300288f..e59c7a12 100644 --- a/crypto/modes/tests/sp800_38c_tests.rs +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -609,3 +609,46 @@ fn sizes_match_the_documented_memory_table() { ); assert!(size_of::>() >= 2 * 4096); } + +// ---- moved from crypto/modes/src/ccm.rs's in-file unit tests ----------------------------- + +/// A.1's `p < 2^8q`. With `n = 13`, `q = 2`, so the limit is 65535 and 65536 must be refused. +/// +/// Only the public API is exercised, so this belongs here rather than in `ccm.rs`'s own +/// `#[cfg(test)]` block, which is for the private formatting helpers no public API reaches. +#[test] +fn payload_longer_than_the_q_limit_is_refused() { + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c]; + assert!( + Ccm::::new(&k, &nonce, &[], 65535).is_ok(), + "2^16 - 1 is the largest payload q = 2 can encode" + ); + assert!( + matches!( + Ccm::::new(&k, &nonce, &[], 65536), + Err(SymmetricCipherError::GenericError(_)) + ), + "2^16 does not fit [p]_16" + ); +} + +/// The declared payload length is inside `B0`, so neither direction may be finalized with the +/// wrong amount of data. +#[test] +fn a_short_or_long_payload_is_refused() { + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + let mut ccm = Ccm::::new(&k, &nonce, &[], 8).unwrap(); + let mut too_much = [0u8; 9]; + assert!( + matches!(ccm.do_encrypt_update(&mut too_much), Err(SymmetricCipherError::StateError(_))), + "9 bytes against a declared 8" + ); + let mut some = [0u8; 4]; + ccm.do_encrypt_update(&mut some).expect("4 of the 8 declared bytes"); + assert!( + matches!(ccm.do_encrypt_final(), Err(SymmetricCipherError::StateError(_))), + "finalizing 4 bytes short" + ); +} diff --git a/crypto/modes/tests/wycheproof_ccm_tests.rs b/crypto/modes/tests/wycheproof_ccm_tests.rs new file mode 100644 index 00000000..4c141ddb --- /dev/null +++ b/crypto/modes/tests/wycheproof_ccm_tests.rs @@ -0,0 +1,305 @@ +//! Known-answer tests against Project Wycheproof's `aes_ccm_test.json`, vendored into +//! `bc-test-data/crypto/wycheproof/` alongside the sibling `sm4_ccm_test.json`. +//! +//! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` +//! relative to the root of this git project. If it is absent the test prints a warning and passes, +//! matching the convention used by the ACVP suite in this crate. +//! +//! # Why this set is worth having alongside the ACVP one +//! +//! `acvp_ccm_tests.rs` covers 480 cases, but every one of them uses a 96-bit nonce, and the only +//! failures it carries are tag-check failures on an otherwise well-formed message. Wycheproof's +//! set is deliberately adversarial in the ways ACVP is not: malformed and truncated tags, every +//! nonce length from 8 to 2144 *bits* (most of which A.1 does not permit at all), a tag size of +//! 16 bits that SP 800-38C Appendix B.2 calls insecure, and pseudorandom sizes meant to catch an +//! implementation that only handles the common cases. See +//! `bc-test-data/crypto/wycheproof/aes_ccm_test.json`'s own `"notes"` object for exactly what each +//! `flags` entry is checking. +//! +//! # Ciphertext and tag are separate fields, unlike the ACVP set +//! +//! Wycheproof's AEAD schema carries `ct` and `tag` as distinct fields (the `aead_test_schema_v1` +//! schema), so these cases go through [`Ccm::encrypt_detached`] / [`Ccm::decrypt_detached`], not +//! the inline pair `acvp_ccm_tests.rs` uses. +//! +//! # Most of the parameter space cannot be dispatched to at all, by design +//! +//! `Ccm`'s `NONCE_LEN` and `TAG_LEN` are const generics restricted to A.1's sets -- +//! `NONCE_LEN` in `7..=13` bytes, `TAG_LEN` in `{4, 6, 8, 10, 12, 14, 16}` bytes -- so there is no +//! instantiation to dispatch a group whose `ivSize`/`tagSize` falls outside them to at all; unlike +//! a runtime check, this is not something a case can "fail", because it is a compile-time property +//! of the type, not a value the library ever sees. Those groups (most of the file: the point of +//! `InvalidNonceSize`/`InvalidTagSize` and most of the `Pseudorandom` groups is to probe exactly +//! this boundary) are counted as skipped rather than silently dropped, and the counts are asserted +//! at the end so a change in the vector file's shape is visible. + +use bouncycastle_aes::{AES_128, AES_192, AES_256}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::{ElectronicCodeBook, SecurityStrength}; +use bouncycastle_hex as hex; +use bouncycastle_modes::{Ccm, Decrypting, Encrypting}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Candidate locations, covering `cargo test` run from the crate root or from the repo root. +const TEST_DATA_PATHS: [&str; 2] = [ + "../../../bc-test-data/crypto/wycheproof/aes_ccm_test.json", + "../bc-test-data/crypto/wycheproof/aes_ccm_test.json", +]; + +fn test_data_file() -> Option { + for candidate in TEST_DATA_PATHS { + let path = Path::new(candidate); + if path.exists() { + return Some(path.to_path_buf()); + } + } + println!( + "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ + Wycheproof AES-CCM tests will be skipped" + ); + None +} + +fn decode(value: &Value, field: &str, tc_id: u64) -> Vec { + let s = value + .get(field) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {field}")); + hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {field}")) +} + +/// Wraps the vector's raw key bytes, promoting them if `KeyMaterial`'s entropy heuristic declined +/// to call them a cipher key. Same helper as the ACVP suite in this crate. +fn cipher_key(bytes: &[u8]) -> KeyMaterial { + assert_eq!(bytes.len(), N, "key length should match the parameter set"); + let mut key = KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("wycheproof key bytes fit the buffer"); + + if key.key_type() != KeyType::SymmetricCipherKey { + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::from_bytes(N)) + }) + .expect("promoting a wycheproof test key"); + } + key +} + +/// Runs one case at a fully-instantiated `(KEY_LEN, NONCE_LEN, TAG_LEN, P)`. +/// +/// For a `result: "valid"` case, `msg` must encrypt to exactly `expected_ct`/`expected_tag` +/// ([`Ccm::encrypt_detached`]), and `expected_ct`/`expected_tag` must decrypt back to `msg` +/// ([`Ccm::decrypt_detached`]). For `result: "invalid"`, only the decrypt direction is checked -- +/// re-encrypting `msg` has no reason to reproduce a deliberately corrupted `ct`/`tag` -- and it +/// must fail the tag check rather than return a payload. +#[allow(clippy::too_many_arguments)] +fn run_case( + tc_id: u64, + key_bytes: &[u8], + nonce_bytes: &[u8], + aad: &[u8], + msg: &[u8], + expected_ct: &[u8], + expected_tag: &[u8], + valid: bool, +) where + P: ElectronicCodeBook, +{ + let key = cipher_key::(key_bytes); + let nonce: [u8; NONCE_LEN] = + nonce_bytes.try_into().unwrap_or_else(|_| panic!("tcId {tc_id}: bad nonce length")); + let tag: [u8; TAG_LEN] = + expected_tag.try_into().unwrap_or_else(|_| panic!("tcId {tc_id}: bad tag length")); + + if valid { + let mut ct = vec![0u8; msg.len()]; + let (written, got_tag) = + Ccm::::encrypt_detached( + &key, &nonce, aad, msg, &mut ct, + ) + .unwrap_or_else(|e| panic!("tcId {tc_id}: valid case failed to encrypt: {e:?}")); + assert_eq!(written, msg.len(), "tcId {tc_id}: encrypt_detached writes exactly msg.len()"); + assert_eq!(ct, expected_ct, "tcId {tc_id}: ciphertext mismatch"); + assert_eq!(got_tag, tag, "tcId {tc_id}: tag mismatch"); + } + + let mut plaintext = vec![0u8; expected_ct.len()]; + match Ccm::::decrypt_detached( + &key, &nonce, aad, expected_ct, &tag, &mut plaintext, + ) { + Ok(n) => { + assert!(valid, "tcId {tc_id}: an invalid vector decrypted and verified anyway"); + plaintext.truncate(n); + assert_eq!(plaintext, msg, "tcId {tc_id}: decrypted plaintext mismatch"); + } + Err(SymmetricCipherError::AEADTagCheckFailed) => { + assert!(!valid, "tcId {tc_id}: a valid vector failed its tag check"); + } + Err(e) => panic!("tcId {tc_id}: unexpected CCM error: {e:?}"), + } +} + +/// Dispatches to one of the 3 (key) x 7 (nonce) x 7 (tag) valid instantiations, or reports that +/// the case's parameter sizes have no instantiation to dispatch to at all. +#[allow(clippy::too_many_arguments)] +fn dispatch( + tc_id: u64, + key_len_bytes: u64, + nonce_len_bytes: u64, + tag_len_bytes: u64, + key_bytes: &[u8], + nonce_bytes: &[u8], + aad: &[u8], + msg: &[u8], + expected_ct: &[u8], + expected_tag: &[u8], + valid: bool, +) -> bool { + macro_rules! with_key_len { + ($n:literal, $t:literal) => { + match key_len_bytes { + 16 => { + run_case::<16, $n, $t, AES_128>( + tc_id, key_bytes, nonce_bytes, aad, msg, expected_ct, expected_tag, valid, + ); + true + } + 24 => { + run_case::<24, $n, $t, AES_192>( + tc_id, key_bytes, nonce_bytes, aad, msg, expected_ct, expected_tag, valid, + ); + true + } + 32 => { + run_case::<32, $n, $t, AES_256>( + tc_id, key_bytes, nonce_bytes, aad, msg, expected_ct, expected_tag, valid, + ); + true + } + _ => false, + } + }; + } + macro_rules! with_tag_len { + ($n:literal) => { + match tag_len_bytes { + 4 => with_key_len!($n, 4), + 6 => with_key_len!($n, 6), + 8 => with_key_len!($n, 8), + 10 => with_key_len!($n, 10), + 12 => with_key_len!($n, 12), + 14 => with_key_len!($n, 14), + 16 => with_key_len!($n, 16), + _ => false, + } + }; + } + match nonce_len_bytes { + 7 => with_tag_len!(7), + 8 => with_tag_len!(8), + 9 => with_tag_len!(9), + 10 => with_tag_len!(10), + 11 => with_tag_len!(11), + 12 => with_tag_len!(12), + 13 => with_tag_len!(13), + _ => false, + } +} + +#[test] +fn wycheproof_aes_ccm_known_answer_tests() { + let Some(path) = test_data_file() else { return }; + + let doc: Value = serde_json::from_str(&fs::read_to_string(&path).expect("readable file")) + .expect("valid wycheproof JSON"); + + let groups = doc.get("testGroups").and_then(Value::as_array).expect("testGroups"); + + let mut run = 0usize; + let mut valid_count = 0usize; + let mut invalid_count = 0usize; + let mut skipped_groups = 0usize; + let mut skipped_cases = 0usize; + + for group in groups { + let iv_size_bits = group.get("ivSize").and_then(Value::as_u64).expect("ivSize"); + let key_size_bits = group.get("keySize").and_then(Value::as_u64).expect("keySize"); + let tag_size_bits = group.get("tagSize").and_then(Value::as_u64).expect("tagSize"); + assert_eq!(iv_size_bits % 8, 0, "ivSize must be a whole number of octets"); + assert_eq!(key_size_bits % 8, 0, "keySize must be a whole number of octets"); + assert_eq!(tag_size_bits % 8, 0, "tagSize must be a whole number of octets"); + + // A group is only fully within A.1's dispatchable sets if its *declared* nonce/tag sizes + // are; a `Pseudorandom` group whose individual tests vary can still contribute some + // dispatched and some skipped cases, so this is a per-group tally for the printout, not + // something the per-case counts below depend on. + if !(7..=13).contains(&(iv_size_bits / 8)) + || ![4u64, 6, 8, 10, 12, 14, 16].contains(&(tag_size_bits / 8)) + { + skipped_groups += 1; + } + + let tests = group.get("tests").and_then(Value::as_array).expect("tests"); + + // Each case is dispatched on its own actual field lengths, not the group's declared + // sizes: a `Pseudorandom` group's whole point is varying them per test, and `dispatch` + // itself is the authority on what it can run (only A.1's own sets). + for test in tests { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + let key_bytes = decode(test, "key", tc_id); + let nonce_bytes = decode(test, "iv", tc_id); + let aad = decode(test, "aad", tc_id); + let msg = decode(test, "msg", tc_id); + let ct = decode(test, "ct", tc_id); + let tag = decode(test, "tag", tc_id); + let result = test.get("result").and_then(Value::as_str).expect("result"); + let valid = match result { + "valid" => true, + "invalid" => false, + other => panic!("tcId {tc_id}: unexpected result {other}"), + }; + + let ran = dispatch( + tc_id, + key_bytes.len() as u64, + nonce_bytes.len() as u64, + tag.len() as u64, + &key_bytes, + &nonce_bytes, + &aad, + &msg, + &ct, + &tag, + valid, + ); + + if ran { + run += 1; + if valid { + valid_count += 1; + } else { + invalid_count += 1; + } + } else { + skipped_cases += 1; + } + } + } + + println!( + "Wycheproof AES-CCM: {run} cases run ({valid_count} valid, {invalid_count} invalid), \ + {skipped_cases} cases in {skipped_groups} groups skipped (no A.1 instantiation)" + ); + + // Guards against a silently-vacuous run: at least the common 96-bit-nonce/128-bit-tag groups + // must have been dispatched to and must have included both valid and invalid cases. + assert!(run > 0, "expected at least some cases to be within A.1's dispatchable sets"); + assert!(valid_count > 0, "expected at least some valid cases to be run"); + assert!(invalid_count > 0, "expected at least some invalid (tag-failure) cases to be run"); + assert!(skipped_groups > 0, "expected most of this adversarial set to be outside A.1's sets"); +} From 199bd562f9bb72f1c3923816332236cf37f1f396 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 16 Sep 2026 02:41:53 +0700 Subject: [PATCH 24/26] modes: close the mutation-testing gaps the batching and buffer-boundary changes left Scoped cargo-mutants (apply_keystream, counter_block, CcmBuffer) found 7 survivors after the F5/F7/F10 commits: 3 on apply_keystream's head_len comparison/subtraction, 2 more on the same expression, and 2 on CcmBuffer::do_update_aad/do_update_out's `end > BUFFER_LEN` checks. The two BUFFER_LEN checks were genuinely untested at the exact boundary (end == BUFFER_LEN, which must be accepted, not refused) -- added the_buffering_pair_accepts_a_message_that_exactly_fills_its_buffer. apply_keystream's gap needed an actual bug, caught it, then a second attempt to test it: no existing test ever calls it with `ks_pos` genuinely strictly between 0 and BLOCK_LEN followed by a chunk large enough to reach the batched fours/pairs path -- every chunking sp800_38c_tests.rs sweeps is uniform, and Appendix C.4's 32-byte payload (Plen = 256 *bits*, not bytes) is too short regardless. Added resuming_a_part_way_open_block_agrees_with_a_one_shot, a dedicated 123-byte case; verified by hand-applying each surviving mutation and confirming it now fails before restoring the correct code. One mutant remains and is provably equivalent (`<` vs `<=` on `ks_pos < BLOCK_LEN`, since `ks_pos` never exceeds `BLOCK_LEN` and both arms agree at that boundary) -- same class as format_b0's documented `|`/`^` equivalence, now commented the same way. Re-run: 34 caught, 116 unviable, 1 equivalent, 0 missed. --- crypto/modes/src/ccm.rs | 5 +++ crypto/modes/tests/sp800_38c_tests.rs | 60 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index e3861ea9..35d947ae 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -617,6 +617,11 @@ where /// the batch paths, only the two ends go byte by byte. #[inline] fn apply_keystream(&mut self, data: &mut [u8]) { + // `ks_pos` never exceeds `BLOCK_LEN` (it is reset to 0 on refill and only ever + // incremented up to it), so at the one point `<` and `<=` disagree -- `ks_pos == + // BLOCK_LEN` -- both give `head_len = 0`: the `if` arm's `BLOCK_LEN - BLOCK_LEN` matches + // the `else` arm exactly. `cargo mutants` reports `<` to `<=` as a surviving mutant; it + // is provably equivalent, not a gap, for the same reason `format_b0`'s `|`/`^` ones are. let head_len = if self.ks_pos < BLOCK_LEN { BLOCK_LEN - self.ks_pos } else { 0 }; let (head, rest) = data.split_at_mut(core::cmp::min(head_len, data.len())); self.apply_keystream_bytes(head); diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs index e59c7a12..f3f7a375 100644 --- a/crypto/modes/tests/sp800_38c_tests.rs +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -462,6 +462,66 @@ fn the_buffering_pair_refuses_a_message_past_its_buffer() { assert!(matches!(enc.do_update_aad(&[0u8; 33]), Err(SymmetricCipherError::GenericError(_)))); } +/// Filling `BUFFER_LEN` *exactly* must be accepted, not refused: `CcmBuffer::do_update_aad` / +/// `do_update_out` check `end > BUFFER_LEN`, so using the whole buffer is legitimate and only one +/// byte more is not. Both boundary sides, in one call and split across two. +#[test] +fn the_buffering_pair_accepts_a_message_that_exactly_fills_its_buffer() { + type Enc = CcmEncryptor; + let k = key::<16>(APPENDIX_C_KEY); + let mut nothing = [0u8; 0]; + + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert_eq!(enc.do_update_out(&[0u8; 32], &mut nothing).expect("exactly fills BUFFER_LEN"), 0); + + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert_eq!(enc.do_update_out(&[0u8; 20], &mut nothing).expect("fits"), 0); + assert_eq!( + enc.do_update_out(&[0u8; 12], &mut nothing).expect("exactly fills the remaining space"), + 0 + ); + + let (mut enc, _) = Enc::do_encrypt_init(&k).expect("init"); + assert!(enc.do_update_aad(&[0u8; 32]).is_ok(), "AAD exactly filling BUFFER_LEN is accepted"); +} + +/// Resuming a part-way-open keystream block into the batched fours/pairs path. +/// +/// None of the Appendix C vectors are long enough for this: the largest, C.4, is 32 bytes (two +/// blocks), too short for a small opening call to leave enough afterwards to reach +/// `apply_keystream_batch`'s fours/pairs path at all. Every chunking `check_vector` sweeps is also +/// *uniform*, so the only call that can ever see `ks_pos` strictly between `0` and `BLOCK_LEN` on +/// entry is a small final remainder -- never one big enough to batch. A first small, +/// non-block-aligned call followed by one call spanning several whole blocks exercises exactly +/// that: the batched blocks must still line up with the keystream the small call left partway +/// through, not silently skip over it. Checked against a one-shot encryption of the identical +/// plaintext, which does not go anywhere near this split. +#[test] +fn resuming_a_part_way_open_block_agrees_with_a_one_shot() { + type Enc = Ccm; + let k = key::<16>(APPENDIX_C_KEY); + let nonce = [0x24u8; 12]; + let aad = b"header"; + // Long enough that, after a several-byte opening call, what remains spans at least one + // four-block batch and one pair-block batch (4 + 2 = 6 blocks = 96 bytes) plus a short tail. + let plaintext: Vec = (0..123u8).collect(); + + let mut reference = vec![0u8; plaintext.len()]; + let (_, reference_tag) = + Enc::encrypt_detached(&k, &nonce, aad, &plaintext, &mut reference).expect("one-shot"); + + for first in [1usize, 3, 5, 15] { + let mut ccm = Enc::new(&k, &nonce, aad, plaintext.len()).expect("streaming init"); + let mut streamed = plaintext.clone(); + let (head, rest) = streamed.split_at_mut(first); + ccm.do_encrypt_update(head).expect("small first update"); + ccm.do_encrypt_update(rest).expect("large second update"); + let tag = ccm.do_encrypt_final().expect("final"); + assert_eq!(streamed, reference, "ciphertext, resuming a {first}-byte-open block"); + assert_eq!(tag, reference_tag, "tag, resuming a {first}-byte-open block"); + } +} + /// Sec 6.2 step 1: "If Clen <= Tlen, then return INVALID". The inline layout has to reject a `C` /// too short to contain a tag before it can split one off. /// From 26dda1382f2135069ba3daf9ad605c0ff7ce09aa Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Tue, 22 Sep 2026 03:11:33 +0700 Subject: [PATCH 25/26] modes: adapt CCM buffer errors to the #120 API --- crypto/modes/src/ccm.rs | 16 +++++----------- crypto/modes/tests/sp800_38c_tests.rs | 12 +++++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/crypto/modes/src/ccm.rs b/crypto/modes/src/ccm.rs index 35d947ae..61b47df7 100644 --- a/crypto/modes/src/ccm.rs +++ b/crypto/modes/src/ccm.rs @@ -723,7 +723,7 @@ where /// the tag. For the spec's own inline `ciphertext || tag` string, use [`Self::encrypt`]. /// /// # Errors - /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `ciphertext` is too short, plus + /// [`SymmetricCipherError::OutputBufferTooSmall`] if `ciphertext` is too short, plus /// [`Self::new`]'s errors. pub fn encrypt_detached( key: &KeyMaterial, @@ -733,10 +733,7 @@ where ciphertext: &mut [u8], ) -> Result<(usize, [u8; TAG_LEN]), SymmetricCipherError> { if ciphertext.len() < plaintext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "ciphertext", - plaintext.len(), - )); + return Err(SymmetricCipherError::OutputBufferTooSmall(plaintext.len())); } let mut ccm = Self::new(key, nonce, aad, plaintext.len())?; let out = &mut ciphertext[..plaintext.len()]; @@ -762,7 +759,7 @@ where ) -> Result { let needed = plaintext.len() + TAG_LEN; if ciphertext.len() < needed { - return Err(SymmetricCipherError::IncorrectOutputBufferLength("ciphertext", needed)); + return Err(SymmetricCipherError::OutputBufferTooSmall(needed)); } let (data, tag_out) = ciphertext[..needed].split_at_mut(plaintext.len()); let (_, tag) = Self::encrypt_detached(key, nonce, aad, plaintext, data)?; @@ -828,7 +825,7 @@ where /// /// # Errors /// [`SymmetricCipherError::AEADTagCheckFailed`] if the tag does not verify, - /// [`SymmetricCipherError::IncorrectOutputBufferLength`] if `plaintext` is too short, plus + /// [`SymmetricCipherError::OutputBufferTooSmall`] if `plaintext` is too short, plus /// [`Self::new`]'s errors. pub fn decrypt_detached( key: &KeyMaterial, @@ -839,10 +836,7 @@ where plaintext: &mut [u8], ) -> Result { if plaintext.len() < ciphertext.len() { - return Err(SymmetricCipherError::IncorrectOutputBufferLength( - "plaintext", - ciphertext.len(), - )); + return Err(SymmetricCipherError::OutputBufferTooSmall(ciphertext.len())); } let mut ccm = Self::new(key, nonce, aad, ciphertext.len())?; let out = &mut plaintext[..ciphertext.len()]; diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs index f3f7a375..abca07cf 100644 --- a/crypto/modes/tests/sp800_38c_tests.rs +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -42,11 +42,9 @@ fn is_tag_failure(r: Result) -> bool { matches!(r, Err(SymmetricCipherError::AEADTagCheckFailed)) } -fn buffer_len_error(r: Result) -> Option<(&'static str, usize)> { +fn buffer_len_error(r: Result) -> Option { match r { - Err(SymmetricCipherError::IncorrectOutputBufferLength(which, needed)) => { - Some((which, needed)) - } + Err(SymmetricCipherError::OutputBufferTooSmall(needed)) => Some(needed), _ => None, } } @@ -564,13 +562,13 @@ fn undersized_output_buffers_are_refused() { let mut too_small = [0u8; 23]; assert_eq!( buffer_len_error(Enc::encrypt_detached(&k, &nonce, &[], &plaintext, &mut too_small)), - Some(("ciphertext", 24)) + Some(24) ); let mut too_small = [0u8; 39]; assert_eq!( buffer_len_error(Enc::encrypt(&k, &nonce, &[], &plaintext, &mut too_small)), - Some(("ciphertext", 40)) + Some(40) ); let mut ct = [0u8; 40]; @@ -578,7 +576,7 @@ fn undersized_output_buffers_are_refused() { let mut too_small = [0u8; 23]; assert_eq!( buffer_len_error(Dec::decrypt(&k, &nonce, &[], &ct, &mut too_small)), - Some(("plaintext", 24)) + Some(24) ); } From d362f4615286cc830771af5eb5d267a4539701e1 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Tue, 22 Sep 2026 03:16:07 +0700 Subject: [PATCH 26/26] Fixed formatting with cargo fmt (#125) --- crypto/modes/src/lib.rs | 6 +++--- crypto/modes/tests/sp800_38c_tests.rs | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs index 1e49c59a..81d81201 100644 --- a/crypto/modes/src/lib.rs +++ b/crypto/modes/src/lib.rs @@ -696,9 +696,9 @@ pub use ecb::Ecb; // Imports needed for docs #[allow(unused_imports)] use bouncycastle_core::traits::{ - AEADCipherDecryptor, AEADCipherEncryptor, - BlockCipherDecryptor, BlockCipherEncryptor, ElectronicCodeBook, StreamCipherDecryptor, - StreamCipherEncryptor, SymmetricCipherDecryptor, SymmetricCipherEncryptor, + AEADCipherDecryptor, AEADCipherEncryptor, BlockCipherDecryptor, BlockCipherEncryptor, + ElectronicCodeBook, StreamCipherDecryptor, StreamCipherEncryptor, SymmetricCipherDecryptor, + SymmetricCipherEncryptor, }; // end of imports needed for docs diff --git a/crypto/modes/tests/sp800_38c_tests.rs b/crypto/modes/tests/sp800_38c_tests.rs index abca07cf..f36055c4 100644 --- a/crypto/modes/tests/sp800_38c_tests.rs +++ b/crypto/modes/tests/sp800_38c_tests.rs @@ -574,10 +574,7 @@ fn undersized_output_buffers_are_refused() { let mut ct = [0u8; 40]; Enc::encrypt(&k, &nonce, &[], &plaintext, &mut ct).expect("encryption"); let mut too_small = [0u8; 23]; - assert_eq!( - buffer_len_error(Dec::decrypt(&k, &nonce, &[], &ct, &mut too_small)), - Some(24) - ); + assert_eq!(buffer_len_error(Dec::decrypt(&k, &nonce, &[], &ct, &mut too_small)), Some(24)); } /// A key of the wrong [`KeyType`] is rejected by every entry point, in both directions.