From b5fcf99252c22b3222bc51bacdb3e7e751516fed Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 9 Sep 2026 23:58:37 +0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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)) }