diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 210a5aeb..238fadd7 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -3,3 +3,33 @@ ## Major features ## Minor features / bug fixes + +Bit-oriented messages: + +* `Hash::do_final_partial_bits()` / `do_final_partial_bits_out()` accept `num_partial_bits` in 0..=7 (0 meaning the + message ends on a byte boundary); larger values return `HashError::InvalidLength` instead of panicking. The convention + is the same for every hash family: the trailing bits are in the least significant bits of `partial_byte` (FIPS 202 + Appendix B.1) -- see the `Hash` trait docs, including the note on the MSB-first packing used by the NIST CAVP SHA-2 + vector files. + +SHA-3 / SHAKE bug fixes: + +* Fixed `XOF::squeeze_partial_byte_final()`: when it was the first squeeze it bypassed the SHAKE `1111` domain suffix + and returned raw Keccak output, and it returned the *high* rather than the low `num_bits` bits of the output byte. + The existing test used `0xFF`, which masked the second error. +* Fixed `XOF::absorb_last_partial_byte()` for `num_partial_bits == 4`: the 4 message bits plus the `1111` suffix + exactly filled a byte and the sponge did not switch to squeezing, so the first squeeze appended the suffix a second + time. Every SHAKE message with a bit length of 4 mod 8 was affected. Found while building the CAVP SHA3VS harness. +* `absorb_last_partial_byte()` and `do_final_partial_bits*()` now validate `num_partial_bits` before use; previously + SHA-3 accepted 8..15 and absorbed garbage, panicked for >= 16, and SHAKE rejected 0 with an error message claiming + `[0,7]`. +* Interleaving absorb -> squeeze -> absorb remains rejected with `HashError::InvalidState`; the `XOF` trait docs now + explain why (it is the duplex construction, not SHAKE). + +SHA-3 internals: + +* SHA-3 finalization is now a single private `do_final_bits_out()` shared by `do_final_out()` and + `do_final_partial_bits_out()`, so the domain-separation suffix, padding and output truncation are applied in exactly + one place. +* `HashAlgParams` for the SHA-3 types is now forwarded from the `*Params` structs, so `OUTPUT_LEN` / `BLOCK_LEN` are + defined once. Removed misleading leftover SHA-2 block-size comments. diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 7e23d516..356ab65b 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -310,7 +310,15 @@ pub trait Hash: Algorithm + Default { fn do_final_out(self, output: &mut [u8]) -> usize; /// The same as [`Hash::do_final`], but allows for supplying a partial byte as the last input. - /// Assumes that the input is in the least significant bits (big endian). + /// The `num_partial_bits` message bits are taken from the least significant bits of + /// `partial_byte`, in order (bit 0 of `partial_byte` is the first message bit). This is the + /// FIPS 202 Appendix B.1 convention and is used uniformly for every hash family in this library, + /// including SHA-2, for which FIPS 180-4 defines no bit-to-byte packing. Note that the NIST CAVP + /// SHAVS (SHA-2) test vector files pack trailing bits MSB-first (left-justified) and must be + /// shifted right by `8 - num_partial_bits` before being passed here; the SHA3VS files already use + /// the LSB convention. + /// `num_partial_bits` must be in `0..=7`; 0 is valid and means the message ends on a byte + /// boundary (equivalent to [`Hash::do_final`]). Larger values return [`HashError::InvalidLength`]. fn do_final_partial_bits( self, partial_byte: u8, @@ -318,7 +326,15 @@ pub trait Hash: Algorithm + Default { ) -> Result, HashError>; /// The same as [`Hash::do_final_out`], but allows for supplying a partial byte as the last input. - /// Assumes that the input is in the least significant bits (big endian). + /// The `num_partial_bits` message bits are taken from the least significant bits of + /// `partial_byte`, in order (bit 0 of `partial_byte` is the first message bit). This is the + /// FIPS 202 Appendix B.1 convention and is used uniformly for every hash family in this library, + /// including SHA-2, for which FIPS 180-4 defines no bit-to-byte packing. Note that the NIST CAVP + /// SHAVS (SHA-2) test vector files pack trailing bits MSB-first (left-justified) and must be + /// shifted right by `8 - num_partial_bits` before being passed here; the SHA3VS files already use + /// the LSB convention. + /// `num_partial_bits` must be in `0..=7`; 0 is valid and means the message ends on a byte + /// boundary (equivalent to [`Hash::do_final_out`]). Larger values return [`HashError::InvalidLength`]. /// will be placed in the first [`Hash::output_len`] bytes. /// The entire output buffer is zeroized before the hash output is written. /// The return value is the number of bytes written. @@ -1057,6 +1073,14 @@ pub trait SignatureVerifier< /// to break anonymity-preserving technology. /// Applications that require the arbitrary-length output of an XOF, but also care about these /// distinguishing attacks should consider adding a cryptographic salt to diversify the inputs. +/// +/// # Absorbing after squeezing +/// Once squeezing has begun, further calls to [`XOF::absorb`] / [`XOF::absorb_last_partial_byte`] +/// return [`HashError::InvalidState`] and leave the object usable for further squeezing. FIPS 202 +/// defines SHAKE as a function of a single, complete message; the sponge's absorb/squeeze phases are +/// internal to computing it. Interleaving absorb → squeeze → absorb → squeeze is the *duplex* +/// construction, which is a different (unapproved) primitive whose output is not the SHAKE of any +/// message and is not reproducible by other SHAKE implementations, so it is deliberately rejected. pub trait XOF: Default { /// A static one-shot API that digests the input data and produces `result_len` bytes of output. fn hash_xof(self, data: &[u8], result_len: usize) -> Vec; @@ -1069,7 +1093,9 @@ pub trait XOF: Default { /// Absorb some amount of input. fn absorb(&mut self, data: &[u8]) -> Result<(), HashError>; - /// Switches to squeezing. + /// Absorbs the final `num_partial_bits` (`0..=7`, least significant bits of `partial_byte`) of the + /// message and switches to squeezing. 0 is valid and means the message ends on a byte boundary. + /// Values above 7 return [`HashError::InvalidLength`]. fn absorb_last_partial_byte( &mut self, partial_byte: u8, @@ -1084,8 +1110,11 @@ pub trait XOF: Default { /// The entire output buffer is zeroized before the output is written. fn squeeze_out(&mut self, output: &mut [u8]) -> usize; - /// Squeezes a partial byte from the XOF. - /// Output will be in the top `num_bits` bits of the returned u8 (ie Big Endian). + /// Squeezes a partial byte (`num_bits` in `1..=7`) from the XOF. + /// The bits are returned in the least significant `num_bits` bits of the returned u8, with the + /// remaining high bits zero. This follows the FIPS 202 Appendix B.1 bit-string convention + /// (the first bit of a byte is its least significant bit) and matches the input convention of + /// [`XOF::absorb_last_partial_byte`]. /// This is a final call and consumes self. fn squeeze_partial_byte_final(self, num_bits: usize) -> Result; diff --git a/crypto/sha3/src/keccak.rs b/crypto/sha3/src/keccak.rs index 10f85f44..e0a18db2 100644 --- a/crypto/sha3/src/keccak.rs +++ b/crypto/sha3/src/keccak.rs @@ -250,12 +250,14 @@ impl KeccakInternal { } } + /// Absorbs the final `bits` (0..=7, in the least significant bits of `data`) of the message and + /// switches the sponge to the squeezing phase. `bits == 0` means "no further bits": the sponge is + /// padded and switched to squeezing without absorbing anything. Callers that have already applied a + /// domain-separation suffix rely on this — if the switch did not happen here, a later squeeze would + /// see `squeezing == false` and apply the suffix a second time. pub(super) fn absorb_bits(&mut self, data: u8, bits: usize) -> Result<(), HashError> { - if bits == 0 { - return Ok(()); - } - if !(1..=7).contains(&bits) { - return Err(HashError::InvalidLength("bits must be in the range 1 to 7")); + if bits > 7 { + return Err(HashError::InvalidLength("bits must be in the range 0 to 7")); } if (self.bits_in_queue & 7) != 0 { return Err(HashError::InvalidState("attempt to absorb with odd length queue")); @@ -264,11 +266,13 @@ impl KeccakInternal { return Err(HashError::InvalidState("attempt to absorb while squeezing")); } - let mask = (1 << bits) - 1; - self.data_queue[self.bits_in_queue >> 3] = data & mask; + if bits != 0 { + let mask = (1 << bits) - 1; + self.data_queue[self.bits_in_queue >> 3] = data & mask; - // NOTE: After this, bits_in_queue is no longer a multiple of 8, so no more absorbs will work - self.bits_in_queue += bits; + // NOTE: After this, bits_in_queue is no longer a multiple of 8, so no more absorbs will work + self.bits_in_queue += bits; + } self.pad_and_switch_to_squeezing_phase(); Ok(()) } @@ -514,6 +518,27 @@ mod keccak_tests { println!("n2: {:x?}", &out); } + /// absorb_bits(): 0..=7 bits are accepted and always switch the sponge to squeezing (0 bits + /// included — see the doc comment); 8+ bits are rejected; a second call is rejected as squeezing. + #[test] + fn absorb_bits_range_and_phase() { + for bits in 0..=7usize { + let mut d = KeccakInternal::new(KeccakSize::_256); + d.absorb(b"abc"); + d.absorb_bits(0xFF, bits).unwrap(); + assert!(d.squeezing, "bits={bits}: must switch to squeezing"); + assert!(matches!(d.absorb_bits(0, 1), Err(HashError::InvalidState(_)))); + } + for bits in [8usize, 9, 16, usize::MAX] { + let mut d = KeccakInternal::new(KeccakSize::_256); + assert!( + matches!(d.absorb_bits(0, bits), Err(HashError::InvalidLength(_))), + "bits={bits}" + ); + assert!(!d.squeezing, "rejected call must not change phase"); + } + } + /// Regression test for from_serialized_state's validation of a not-yet-squeezing queue: a corrupt /// state whose bits_in_queue is not byte-aligned, or equals/exceeds the rate, must be rejected as /// InvalidData rather than deserialized into a value that later trips the debug_assert in absorb() diff --git a/crypto/sha3/src/lib.rs b/crypto/sha3/src/lib.rs index bc689b95..40e36cc2 100644 --- a/crypto/sha3/src/lib.rs +++ b/crypto/sha3/src/lib.rs @@ -89,7 +89,7 @@ //! [`KDF`] acts on [`KeyMaterial`] objects as both the input and output values. //! In the case of SHA3, the [`KDF`] interfaces are simple wrapper functions around the underlying SHA3 or SHAKE //! primitive that correctly maintains the length and entropy metadata of the key material that it is acting on. -//! This is intended to act as a developer ait to prevent some classes of developer mistakes, such as +//! This is intended to act as a developer aid to prevent some classes of developer mistakes, such as //! deriving a cryptographic key from uninitialized (aka zeroized) input key material, or using low-entropy //! input key material to derive a MAC, symmetric, or asymmetric key. //! @@ -160,17 +160,17 @@ mod sha3; mod shake; /*** String constants ***/ -/// +/// Algorithm name string for SHA3-224, as used by the factories and CLI. pub const SHA3_224_NAME: &str = "SHA3-224"; -/// +/// Algorithm name string for SHA3-256, as used by the factories and CLI. pub const SHA3_256_NAME: &str = "SHA3-256"; -/// +/// Algorithm name string for SHA3-384, as used by the factories and CLI. pub const SHA3_384_NAME: &str = "SHA3-384"; -/// +/// Algorithm name string for SHA3-512, as used by the factories and CLI. pub const SHA3_512_NAME: &str = "SHA3-512"; -/// +/// Algorithm name string for SHAKE128, as used by the factories and CLI. pub const SHAKE128_NAME: &str = "SHAKE128"; -/// +/// Algorithm name string for SHAKE256, as used by the factories and CLI. pub const SHAKE256_NAME: &str = "SHAKE256"; /*** pub types ***/ @@ -205,11 +205,13 @@ trait SHA3Params: HashAlgParams { // TODO: it would probably be more elegant to macro these. -impl HashAlgParams for SHA3_224 { - const OUTPUT_LEN: usize = 28; - // const BLOCK_LEN: usize = 64; - const BLOCK_LEN: usize = 144; // FIPS 202 Table 3 +/// The public hash types expose the same parameters as their `*Params` marker, so the constants +/// are defined exactly once (on the params struct) and forwarded here. +impl HashAlgParams for SHA3Internal { + const OUTPUT_LEN: usize = PARAMS::OUTPUT_LEN; + const BLOCK_LEN: usize = PARAMS::BLOCK_LEN; } + /// The parameters for SHA3_224. #[derive(Clone)] pub struct SHA3_224Params; @@ -219,7 +221,6 @@ impl Algorithm for SHA3_224Params { } impl HashAlgParams for SHA3_224Params { const OUTPUT_LEN: usize = 28; - // const BLOCK_LEN: usize = 64; const BLOCK_LEN: usize = 144; // FIPS 202 Table 3 } impl SHA3Params for SHA3_224Params { @@ -233,11 +234,6 @@ impl AlgorithmOID for SHA3_224 { &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x07]; } -impl HashAlgParams for SHA3_256 { - const OUTPUT_LEN: usize = 32; - // const BLOCK_LEN: usize = 64; - const BLOCK_LEN: usize = 136; // FIPS 202 Table 3 -} /// The parameters for SHA3_256. #[derive(Clone)] pub struct SHA3_256Params; @@ -247,7 +243,6 @@ impl Algorithm for SHA3_256Params { } impl HashAlgParams for SHA3_256Params { const OUTPUT_LEN: usize = 32; - // const BLOCK_LEN: usize = 64; const BLOCK_LEN: usize = 136; // FIPS 202 Table 3 } impl SHA3Params for SHA3_256Params { @@ -263,18 +258,12 @@ impl AlgorithmOID for SHA3_256 { /// The parameters for SHA3_384. #[derive(Clone)] pub struct SHA3_384Params; -impl HashAlgParams for SHA3_384 { - const OUTPUT_LEN: usize = 48; - // const BLOCK_LEN: usize = 128; - const BLOCK_LEN: usize = 104; // FIPS 202 Table 3 -} impl Algorithm for SHA3_384Params { const ALG_NAME: &'static str = SHA3_384_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; } impl HashAlgParams for SHA3_384Params { const OUTPUT_LEN: usize = 48; - // const BLOCK_LEN: usize = 128; const BLOCK_LEN: usize = 104; // FIPS 202 Table 3 } impl SHA3Params for SHA3_384Params { @@ -290,18 +279,12 @@ impl AlgorithmOID for SHA3_384 { /// The parameters for SHA3_512. #[derive(Clone)] pub struct SHA3_512Params; -impl HashAlgParams for SHA3_512 { - const OUTPUT_LEN: usize = 64; - // const BLOCK_LEN: usize = 128; - const BLOCK_LEN: usize = 72; // FIPS 202 Table 3 -} impl Algorithm for SHA3_512Params { const ALG_NAME: &'static str = SHA3_512_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; } impl HashAlgParams for SHA3_512Params { const OUTPUT_LEN: usize = 64; - // const BLOCK_LEN: usize = 128; const BLOCK_LEN: usize = 72; // FIPS 202 Table 3 } impl SHA3Params for SHA3_512Params { diff --git a/crypto/sha3/src/sha3.rs b/crypto/sha3/src/sha3.rs index 3da20b0b..f152ba5a 100644 --- a/crypto/sha3/src/sha3.rs +++ b/crypto/sha3/src/sha3.rs @@ -44,6 +44,49 @@ impl SHA3Internal { self.do_final_out(output) } + /// Appends the SHA3 domain-separation suffix and pads as per FIPS 202 s. 6.1, then squeezes the digest. + /// + /// Private, infallible body shared by [`Hash::do_final_out`] and [`Hash::do_final_partial_bits_out`]. + /// `num_partial_bits` (0..=7, validated by the caller) trailing message bits are taken from the + /// least significant bits of `partial_byte` (FIPS 202 Appendix B.1 bit ordering). FIPS 202 s. 6.1 + /// defines SHA3-d(M) = KECCAK[c](M || 01, d), so the two suffix bits are appended directly above + /// the message bits; pad10*1 is then applied by the sponge when it switches to squeezing. + /// + /// Returns the number of bytes written (`min(output.len(), OUTPUT_LEN)`); a shorter output buffer + /// truncates the digest, a longer one is zero-filled past the digest. + fn do_final_bits_out( + mut self, + partial_byte: u8, + num_partial_bits: usize, + output: &mut [u8], + ) -> usize { + debug_assert!(num_partial_bits <= 7); + output.fill(0); + + // Mutants note: This is just bit-setting into empty space. + // It works the same regardless of whether it's OR or XOR. + let mut final_input: u16 = + ((partial_byte as u16) & ((1 << num_partial_bits) - 1)) | (0x02 << num_partial_bits); + let mut final_bits = num_partial_bits + 2; + + // If message bits + suffix fill a whole byte, absorb it as a normal byte first. + if final_bits >= 8 { + self.keccak.absorb(&[final_input as u8]); + final_bits -= 8; + final_input >>= 8; + } + + // Infallible: the queue is byte-aligned here, final_bits is in 0..=7 by construction, and a + // Hash object cannot have started squeezing (do_final_bits_out consumes self and is the only squeeze path). + self.keccak + .absorb_bits(final_input as u8, final_bits) + .expect("absorb_bits is infallible on a byte-aligned, not-yet-squeezing Hash"); + + // Truncate to OUTPUT_LEN if the caller supplied a larger buffer (see the Hash trait docs). + let n = *min(&output.len(), &PARAMS::OUTPUT_LEN); + self.keccak.squeeze(&mut output[..n]) + } + fn mix_key_internal(&mut self, key: &impl KeyMaterialTrait) { // track the strongest input key type self.kdf_key_type = *max(&self.kdf_key_type, &key.key_type()); @@ -171,21 +214,9 @@ impl Hash for SHA3Internal { // TODO: investigate why this doesn't take a &mut [u8; HASH_LEN] // Being able to do so would improve ergonomics - fn do_final_out(mut self, output: &mut [u8]) -> usize { - output.fill(0); - - // this shouldn't fail because, by construction, the function is only called once, - // and this is the only way to absorb partial bits. - self.keccak.absorb_bits(0x02, 2).expect("do_final_out: keccak.absorb_bits failed."); - - let bytes_written = if output.len() <= self.output_len() { - self.keccak.squeeze(output) - } else { - let min = - if output.len() >= self.output_len() { self.output_len() } else { output.len() }; - self.keccak.squeeze(&mut output[..min]) - }; - bytes_written + fn do_final_out(self, output: &mut [u8]) -> usize { + // A whole-byte message is the zero-partial-bits case of the general finalization. + self.do_final_bits_out(0, 0, output) } fn do_final_partial_bits( @@ -193,39 +224,22 @@ impl Hash for SHA3Internal { partial_byte: u8, num_partial_bits: usize, ) -> Result, HashError> { - let dbg_rslt_len = self.output_len(); - let mut output: Vec = vec![0u8; self.output_len()]; - let bytes_written = - self.do_final_partial_bits_out(partial_byte, num_partial_bits, output.as_mut_slice())?; - debug_assert_eq!(bytes_written, dbg_rslt_len); - + let mut output: Vec = vec![0u8; PARAMS::OUTPUT_LEN]; + self.do_final_partial_bits_out(partial_byte, num_partial_bits, &mut output)?; Ok(output) } fn do_final_partial_bits_out( - mut self, + self, partial_byte: u8, num_partial_bits: usize, output: &mut [u8], ) -> Result { - output.fill(0); - - // Mutants note: This is just bit-setting into empty space. - // It works the same regardless of whether it's OR or XOR. - let mut final_input: u16 = - ((partial_byte as u16) & ((1 << num_partial_bits) - 1)) | (0x02 << num_partial_bits); - let mut final_bits = num_partial_bits + 2; - - if final_bits >= 8 { - self.keccak.absorb(&[final_input as u8]); - final_bits -= 8; - final_input >>= 8; + // A partial byte has at most 7 bits; 0 means the message ends on a byte boundary. + if num_partial_bits > 7 { + return Err(HashError::InvalidLength("num_partial_bits must be in the range [0,7]")); } - - self.keccak.absorb_bits(final_input as u8, final_bits)?; - - let min = if output.len() >= self.output_len() { self.output_len() } else { output.len() }; - Ok(self.keccak.squeeze(&mut output[..min])) + Ok(self.do_final_bits_out(partial_byte, num_partial_bits, output)) } fn max_security_strength(&self) -> SecurityStrength { diff --git a/crypto/sha3/src/shake.rs b/crypto/sha3/src/shake.rs index 6ba2a882..2661d2c0 100644 --- a/crypto/sha3/src/shake.rs +++ b/crypto/sha3/src/shake.rs @@ -304,8 +304,9 @@ impl XOF for SHAKEInternal { if self.keccak.squeezing { return Err(HashError::InvalidState("cannot absorb after squeezing has begun")); } - if !(1..=7).contains(&num_partial_bits) { - return Err(HashError::InvalidLength("must be in the range [0,7]")); + // A partial byte has at most 7 bits; 0 means the message ends on a byte boundary. + if num_partial_bits > 7 { + return Err(HashError::InvalidLength("num_partial_bits must be in the range [0,7]")); } // Mutants note: This is just bit-setting into empty space. // It works the same regardless of whether it's OR or XOR. @@ -355,14 +356,16 @@ impl XOF for SHAKEInternal { output: &mut u8, ) -> Result<(), HashError> { if !(1..=7).contains(&num_bits) { - return Err(HashError::InvalidLength("must be in the range [0,7]")); + return Err(HashError::InvalidLength("num_bits must be in the range [1,7]")); } *output = 0; + // Via squeeze_out() so the SHAKE "1111" suffix (FIPS 202 s. 6.2) is applied on a first squeeze. let mut buf = [0u8; 1]; - self.keccak.squeeze(&mut buf); - *output = buf[0] >> 8 - num_bits; + self.squeeze_out(&mut buf); + + *output = buf[0] & ((1u8 << num_bits) - 1); Ok(()) } diff --git a/crypto/sha3/tests/sha3_tests.rs b/crypto/sha3/tests/sha3_tests.rs index 306d8816..55ddf6c8 100644 --- a/crypto/sha3/tests/sha3_tests.rs +++ b/crypto/sha3/tests/sha3_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod sha3_tests { use super::sha3_test_helpers::*; + use bouncycastle_core::errors::HashError; use bouncycastle_core::key_material; use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterial256, KeyMaterial512, KeyMaterialTrait, KeyType, @@ -142,6 +143,28 @@ mod sha3_tests { assert_eq!(output, expected_output[..SHA3_224::OUTPUT_LEN - 1]); } + /// do_final_partial_bits() must validate num_partial_bits before shifting: 0 is equivalent to + /// do_final(), 8+ is rejected with InvalidLength rather than panicking (16+ used to overflow a shift). + #[test] + fn partial_bits_range_is_validated() { + for bad in [8usize, 9, 15, 16, 64, usize::MAX] { + let mut h = SHA3_256::new(); + h.do_update(b"abc"); + assert!( + matches!(h.do_final_partial_bits(0xFF, bad), Err(HashError::InvalidLength(_))), + "num_partial_bits={bad}" + ); + let mut out = [0u8; 32]; + assert!(matches!( + SHA3_256::new().do_final_partial_bits_out(0xFF, bad, &mut out), + Err(HashError::InvalidLength(_)) + )); + } + let mut h = SHA3_256::new(); + h.do_update(b"abc"); + assert_eq!(h.do_final_partial_bits(0xFF, 0).unwrap(), SHA3_256::new().hash(b"abc")); + } + #[test] fn test_do_final_out_truncation() { let expected_output = b"\xFE\x51\xC5\xD7\x62\x48\xE1\xE9\xD3\x01\x29\x6A\xE8\xAB\x94\x69\xD2\x86\x34\xB4\xAD\x3E\x9E\x78\xC8\xB0\x9D\x47"; diff --git a/crypto/sha3/tests/shake_tests.rs b/crypto/sha3/tests/shake_tests.rs index 260e6e1d..aa231ab0 100644 --- a/crypto/sha3/tests/shake_tests.rs +++ b/crypto/sha3/tests/shake_tests.rs @@ -3,6 +3,7 @@ extern crate core; #[cfg(test)] mod shake_tests { use super::shake_test_helpers::*; + use bouncycastle_core::errors::HashError; use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterial256, KeyMaterial512, KeyMaterialTrait, KeyType, }; @@ -62,6 +63,77 @@ mod shake_tests { assert_eq!(out, 0x01); } + /// Regression: squeeze_partial_byte_final() as the *first* squeeze must apply the SHAKE "1111" + /// domain suffix (previously it bypassed it and returned raw Keccak output), and must return the + /// low `num_bits` bits of the next output byte (FIPS 202 B.1 bit ordering), zero-extended. + #[test] + fn partial_bit_output_as_first_squeeze_matches_full_output() { + let msg = b"abc"; + for skip in [0usize, 1, 5] { + let mut shake = SHAKE256::new(); + shake.absorb(msg).unwrap(); + let full = shake.squeeze(skip + 1)[skip]; + // pick a byte that is not all-ones/all-zeros so bit selection is actually tested + assert!( + full != 0x00 && full != 0xFF, + "test vector byte must be non-uniform: {full:#x}" + ); + + for n in 1..=7usize { + let mut shake = SHAKE256::new(); + shake.absorb(msg).unwrap(); + if skip > 0 { + _ = shake.squeeze(skip); + } + let got = shake.squeeze_partial_byte_final(n).unwrap(); + assert_eq!(got, full & ((1u8 << n) - 1), "skip={skip} n={n}"); + assert_eq!(got >> n, 0, "high bits must be zero"); + } + } + } + + /// Regression: when the 4 trailing message bits plus the SHAKE "1111" suffix exactly fill a byte, + /// the sponge must still switch to squeezing, otherwise the first squeeze appended a second suffix. + /// Vector: NIST CAVP SHA3VS SHAKE128ShortMsg (bit-oriented), Len = 4, Msg = 08. + #[test] + fn absorb_last_partial_byte_four_bits() { + let mut shake = SHAKE128::new(); + shake.absorb_last_partial_byte(0x08, 4).unwrap(); + assert_eq!( + shake.squeeze(16), + bouncycastle_hex::decode("d40238024b040a954d9c2c89daf480e5").unwrap(), + "SHAKE128 of the 4-bit message 0001" + ); + } + + /// absorb_last_partial_byte() must validate num_partial_bits before shifting: 0 is allowed + /// (finalize with no partial byte), 8+ is rejected with InvalidLength rather than panicking. + #[test] + fn absorb_last_partial_byte_validates_range() { + for bad in [8usize, 9, 15, 16, 64, usize::MAX] { + let mut shake = SHAKE128::new(); + shake.absorb(b"abc").unwrap(); + assert!( + matches!( + shake.absorb_last_partial_byte(0xFF, bad), + Err(HashError::InvalidLength(_)) + ), + "num_partial_bits={bad}" + ); + } + let mut a = SHAKE128::new(); + a.absorb(b"abc").unwrap(); + a.absorb_last_partial_byte(0xFF, 0).unwrap(); + assert_eq!(a.squeeze(32), SHAKE128::new().hash_xof(b"abc", 32)); + + // Upper boundary: 7 bits is the largest valid partial byte and must be accepted, and must + // actually change the output relative to the byte-aligned message. + let mut b = SHAKE128::new(); + b.absorb(b"abc").unwrap(); + b.absorb_last_partial_byte(0x7F, 7).unwrap(); + assert_ne!(b.squeeze(32), SHAKE128::new().hash_xof(b"abc", 32)); + } + /// Once squeezing has begun, a SHAKE cannot return to absorbing (FIPS 202 defines SHAKE as a /// single function of the whole message). Both absorb entry points must reject a post-squeeze call /// with `HashError::InvalidState` rather than panicking, and a rejected call must leave the sponge