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

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

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

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

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

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