diff --git a/.github/workflows/ctutils.yml b/.github/workflows/ctutils.yml index a5770d1f..039c51eb 100644 --- a/.github/workflows/ctutils.yml +++ b/.github/workflows/ctutils.yml @@ -45,10 +45,11 @@ jobs: targets: ${{ matrix.target }} - run: cargo build --target ${{ matrix.target }} - minimal-versions: - uses: RustCrypto/actions/.github/workflows/minimal-versions.yml@master - with: - working-directory: ${{ github.workflow }} + # Disabled until there's a stable `cmov` v0.5.0 release + # minimal-versions: + # uses: RustCrypto/actions/.github/workflows/minimal-versions.yml@master + # with: + # working-directory: ${{ github.workflow }} test: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 4b6e6993..6f1988c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,7 +57,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cmov" -version = "0.4.6" +version = "0.5.0-pre" dependencies = [ "proptest", ] diff --git a/cmov/Cargo.toml b/cmov/Cargo.toml index eacd9acc..72be3176 100644 --- a/cmov/Cargo.toml +++ b/cmov/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cmov" -version = "0.4.6" +version = "0.5.0-pre" authors = ["RustCrypto Developers"] edition = "2024" rust-version = "1.85" diff --git a/cmov/src/array.rs b/cmov/src/array.rs index 5996c5df..63fc8699 100644 --- a/cmov/src/array.rs +++ b/cmov/src/array.rs @@ -1,16 +1,15 @@ //! Trait impls for core arrays. -use crate::{ - Cmov, CmovEq, Condition, - utils::{WORD_SIZE, Word, slice_as_chunks}, -}; +use crate::{Cmov, CmovEq, Condition, slice::cmovnz_slice_unchecked}; /// Optimized implementation for byte arrays which coalesces them into word-sized chunks first, /// then performs [`Cmov`] at the word-level to cut down on the total number of instructions. impl Cmov for [u8; N] { #[inline] fn cmovnz(&mut self, value: &Self, condition: Condition) { - self.as_mut_slice().cmovnz(value, condition); + // "unchecked" means it doesn't check the inputs are equal-length, however they are in this + // context because they're two equal-sized arrays + cmovnz_slice_unchecked(self, value, condition); } } @@ -18,18 +17,6 @@ impl Cmov for [u8; N] { /// then performs [`CmovEq`] at the word-level to cut down on the total number of instructions. impl CmovEq for [u8; N] { fn cmovne(&self, rhs: &Self, input: Condition, output: &mut Condition) { - let (self_chunks, self_remainder) = slice_as_chunks::(self); - let (rhs_chunks, rhs_remainder) = slice_as_chunks::(rhs); - - for (self_chunk, rhs_chunk) in self_chunks.iter().zip(rhs_chunks.iter()) { - let a = Word::from_ne_bytes(*self_chunk); - let b = Word::from_ne_bytes(*rhs_chunk); - a.cmovne(&b, input, output); - } - - // Process the remainder a byte-at-a-time. - for (a, b) in self_remainder.iter().zip(rhs_remainder.iter()) { - a.cmovne(b, input, output); - } + self.as_slice().cmovne(rhs, input, output); } } diff --git a/cmov/src/lib.rs b/cmov/src/lib.rs index 041d03a5..73e13d04 100644 --- a/cmov/src/lib.rs +++ b/cmov/src/lib.rs @@ -28,7 +28,7 @@ )] #[macro_use] -mod utils; +mod macros; #[cfg(not(miri))] #[cfg(target_arch = "aarch64")] diff --git a/cmov/src/macros.rs b/cmov/src/macros.rs new file mode 100644 index 00000000..830b1838 --- /dev/null +++ b/cmov/src/macros.rs @@ -0,0 +1,62 @@ +//! Macro definitions. + +/// Generates a mask the width of the given unsigned integer type `$uint` if the input value is +/// non-zero. +/// +/// Uses `core::hint::black_box` to coerce our desired codegen based on real-world observations +/// of the assembly generated by Rust/LLVM. +/// +/// Implemented as a macro instead of a generic function because it uses functionality for which +/// there aren't available `core` traits, e.g. `wrapping_neg`. +/// +/// See also: +/// - CVE-2026-23519 +/// - RustCrypto/utils#1332 +macro_rules! masknz { + ($value:tt : $uint:ident) => {{ + let mut value: $uint = $value; + value |= value.wrapping_neg(); // has MSB `1` if non-zero, `0` if zero + + // use `black_box` to obscure we're computing a 1-bit value + core::hint::black_box( + value >> ($uint::BITS - 1), // Extract MSB + ) + .wrapping_neg() // Generate $uint::MAX mask if `black_box` outputs `1` + }}; +} + +#[cfg(test)] +mod tests { + // Spot check up to a given limit + const TEST_LIMIT: u8 = 128; + + macro_rules! masknz_test { + ( $($name:ident : $uint:ident),+ ) => { + $( + #[test] + fn $name() { + assert_eq!(masknz!(0: $uint), 0); + + // Test lower values + for i in 1..=$uint::from(TEST_LIMIT) { + assert_eq!(masknz!(i: $uint), $uint::MAX); + } + + // Test upper values + for i in ($uint::MAX - $uint::from(TEST_LIMIT))..=$uint::MAX { + assert_eq!(masknz!(i: $uint), $uint::MAX); + } + } + )+ + } + } + + // Ensure the macro works with any types we might use it with (we only use u8, u32, and u64) + masknz_test!( + masknz_u8: u8, + masknz_u16: u16, + masknz_u32: u32, + masknz_u64: u64, + masknz_u128: u128 + ); +} diff --git a/cmov/src/slice.rs b/cmov/src/slice.rs index 0868e52f..a4b10a4f 100644 --- a/cmov/src/slice.rs +++ b/cmov/src/slice.rs @@ -1,31 +1,43 @@ //! Trait impls for core slices. -use crate::utils::{WORD_SIZE, Word, slice_as_chunks, slice_as_chunks_mut}; use crate::{Cmov, CmovEq, Condition}; +use core::slice; + +// Uses 64-bit words on 64-bit targets, 32-bit everywhere else +#[cfg(not(target_pointer_width = "64"))] +type Word = u32; +#[cfg(target_pointer_width = "64")] +type Word = u64; +const WORD_SIZE: usize = size_of::(); +const _: () = assert!(size_of::() <= WORD_SIZE, "unexpected word size"); /// Optimized implementation for byte slices which coalesces them into word-sized chunks first, /// then performs [`Cmov`] at the word-level to cut down on the total number of instructions. +/// +/// # Panics +/// - if slices have unequal lengths impl Cmov for [u8] { #[inline] fn cmovnz(&mut self, value: &Self, condition: Condition) { - let (self_chunks, self_remainder) = slice_as_chunks_mut::(self); - let (value_chunks, value_remainder) = slice_as_chunks::(value); - - for (self_chunk, value_chunk) in self_chunks.iter_mut().zip(value_chunks.iter()) { - let mut a = Word::from_ne_bytes(*self_chunk); - let b = Word::from_ne_bytes(*value_chunk); - a.cmovnz(&b, condition); - self_chunk.copy_from_slice(&a.to_ne_bytes()); - } + assert_eq!( + self.len(), + value.len(), + "source slice length ({}) does not match destination slice length ({})", + value.len(), + self.len() + ); - // Process the remainder a byte-at-a-time. - for (a, b) in self_remainder.iter_mut().zip(value_remainder.iter()) { - a.cmovnz(b, condition); - } + cmovnz_slice_unchecked(self, value, condition); } } -impl CmovEq for [T] { +/// Optimized implementation for byte arrays which coalesces them into word-sized chunks first, +/// then performs [`CmovEq`] at the word-level to cut down on the total number of instructions. +/// +/// This is only constant-time for equal-length slices, and will short-circuit and set `output` +/// in the event the slices are of unequal length. +impl CmovEq for [u8] { + #[inline] fn cmovne(&self, rhs: &Self, input: Condition, output: &mut Condition) { // Short-circuit the comparison if the slices are of different lengths, and set the output // condition to the input condition. @@ -34,9 +46,109 @@ impl CmovEq for [T] { return; } - // Compare each byte. - for (a, b) in self.iter().zip(rhs.iter()) { + let (self_chunks, self_remainder) = slice_as_chunks::(self); + let (rhs_chunks, rhs_remainder) = slice_as_chunks::(rhs); + + for (self_chunk, rhs_chunk) in self_chunks.iter().zip(rhs_chunks.iter()) { + let a = Word::from_ne_bytes(*self_chunk); + let b = Word::from_ne_bytes(*rhs_chunk); + a.cmovne(&b, input, output); + } + + // Process the remainder a byte-at-a-time. + for (a, b) in self_remainder.iter().zip(rhs_remainder.iter()) { a.cmovne(b, input, output); } } } + +/// Conditionally move `src` to `dst` in constant-time if `condition` is non-zero. +/// +/// This function does not check the slices are equal-length and expects the caller to do so first. +#[inline(always)] +pub(crate) fn cmovnz_slice_unchecked(dst: &mut [u8], src: &[u8], condition: Condition) { + let (dst_chunks, dst_remainder) = slice_as_chunks_mut::(dst); + let (src_chunks, src_remainder) = slice_as_chunks::(src); + + for (dst_chunk, src_chunk) in dst_chunks.iter_mut().zip(src_chunks.iter()) { + let mut a = Word::from_ne_bytes(*dst_chunk); + let b = Word::from_ne_bytes(*src_chunk); + a.cmovnz(&b, condition); + dst_chunk.copy_from_slice(&a.to_ne_bytes()); + } + + // Process the remainder a byte-at-a-time. + for (a, b) in dst_remainder.iter_mut().zip(src_remainder.iter()) { + a.cmovnz(b, condition); + } +} + +/// Rust core `[T]::as_chunks` vendored because of its 1.88 MSRV. +/// TODO(tarcieri): use upstream function when we bump MSRV +#[inline] +#[track_caller] +#[must_use] +#[allow(clippy::integer_division_remainder_used)] +fn slice_as_chunks(slice: &[T]) -> (&[[T; N]], &[T]) { + assert!(N != 0, "chunk size must be non-zero"); + let len_rounded_down = slice.len() / N * N; + // SAFETY: The rounded-down value is always the same or smaller than the + // original length, and thus must be in-bounds of the slice. + let (multiple_of_n, remainder) = unsafe { slice.split_at_unchecked(len_rounded_down) }; + // SAFETY: We already panicked for zero, and ensured by construction + // that the length of the subslice is a multiple of N. + let array_slice = unsafe { slice_as_chunks_unchecked(multiple_of_n) }; + (array_slice, remainder) +} + +/// Rust core `[T]::as_chunks_mut` vendored because of its 1.88 MSRV. +/// TODO(tarcieri): use upstream function when we bump MSRV +#[inline] +#[track_caller] +#[must_use] +#[allow(clippy::integer_division_remainder_used)] +fn slice_as_chunks_mut(slice: &mut [T]) -> (&mut [[T; N]], &mut [T]) { + assert!(N != 0, "chunk size must be non-zero"); + let len_rounded_down = slice.len() / N * N; + // SAFETY: The rounded-down value is always the same or smaller than the + // original length, and thus must be in-bounds of the slice. + let (multiple_of_n, remainder) = unsafe { slice.split_at_mut_unchecked(len_rounded_down) }; + // SAFETY: We already panicked for zero, and ensured by construction + // that the length of the subslice is a multiple of N. + let array_slice = unsafe { slice_as_chunks_unchecked_mut(multiple_of_n) }; + (array_slice, remainder) +} + +/// Rust core `[T]::as_chunks_unchecked` vendored because of its 1.88 MSRV. +/// TODO(tarcieri): use upstream function when we bump MSRV +#[inline] +#[must_use] +#[track_caller] +#[allow(clippy::integer_division_remainder_used)] +unsafe fn slice_as_chunks_unchecked(slice: &[T]) -> &[[T; N]] { + // SAFETY: Caller must guarantee that `N` is nonzero and exactly divides the slice length + const { debug_assert!(N != 0) }; + debug_assert_eq!(slice.len() % N, 0); + let new_len = slice.len() / N; + + // SAFETY: We cast a slice of `new_len * N` elements into + // a slice of `new_len` many `N` elements chunks. + unsafe { slice::from_raw_parts(slice.as_ptr().cast(), new_len) } +} + +/// Rust core `[T]::as_chunks_unchecked_mut` vendored because of its 1.88 MSRV. +/// TODO(tarcieri): use upstream function when we bump MSRV +#[inline] +#[must_use] +#[track_caller] +#[allow(clippy::integer_division_remainder_used)] +unsafe fn slice_as_chunks_unchecked_mut(slice: &mut [T]) -> &mut [[T; N]] { + // SAFETY: Caller must guarantee that `N` is nonzero and exactly divides the slice length + const { debug_assert!(N != 0) }; + debug_assert_eq!(slice.len() % N, 0); + let new_len = slice.len() / N; + + // SAFETY: We cast a slice of `new_len * N` elements into + // a slice of `new_len` many `N` elements chunks. + unsafe { slice::from_raw_parts_mut(slice.as_mut_ptr().cast(), new_len) } +} diff --git a/cmov/src/utils.rs b/cmov/src/utils.rs deleted file mode 100644 index 21c83655..00000000 --- a/cmov/src/utils.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Macro definitions. - -use core::slice; - -/// Generates a mask the width of the input type if the input value is non-zero. -/// -/// Uses `core::hint::black_box` to coerce our desired codegen based on real-world observations -/// of the assembly generated by Rust/LLVM. -/// -/// See also: -/// - CVE-2026-23519 -/// - RustCrypto/utils#1332 -macro_rules! masknz { - ($value:tt : $int:ident) => {{ - let mut value: $int = $value; - value |= value.wrapping_neg(); // has MSB `1` if non-zero, `0` if zero - - // use `black_box` to obscure we're computing a 1-bit value - core::hint::black_box( - value >> ($int::BITS - 1), // Extract MSB - ) - .wrapping_neg() // Generate $int::MAX mask if `black_box` outputs `1` - }}; -} - -// Uses 64-bit words on 64-bit targets, 32-bit everywhere else -#[cfg(not(target_pointer_width = "64"))] -pub(crate) type Word = u32; -#[cfg(target_pointer_width = "64")] -pub(crate) type Word = u64; -pub(crate) const WORD_SIZE: usize = size_of::(); -const _: () = debug_assert!(size_of::() <= WORD_SIZE, "unexpected word size"); - -/// Rust core `[T]::as_chunks` vendored because of its 1.88 MSRV. -/// TODO(tarcieri): use upstream function when we bump MSRV -#[inline] -#[track_caller] -#[must_use] -#[allow(clippy::integer_division_remainder_used)] -pub(crate) fn slice_as_chunks(slice: &[T]) -> (&[[T; N]], &[T]) { - assert!(N != 0, "chunk size must be non-zero"); - let len_rounded_down = slice.len() / N * N; - // SAFETY: The rounded-down value is always the same or smaller than the - // original length, and thus must be in-bounds of the slice. - let (multiple_of_n, remainder) = unsafe { slice.split_at_unchecked(len_rounded_down) }; - // SAFETY: We already panicked for zero, and ensured by construction - // that the length of the subslice is a multiple of N. - let array_slice = unsafe { slice_as_chunks_unchecked(multiple_of_n) }; - (array_slice, remainder) -} - -/// Rust core `[T]::as_chunks_mut` vendored because of its 1.88 MSRV. -/// TODO(tarcieri): use upstream function when we bump MSRV -#[inline] -#[track_caller] -#[must_use] -#[allow(clippy::integer_division_remainder_used)] -pub(crate) fn slice_as_chunks_mut(slice: &mut [T]) -> (&mut [[T; N]], &mut [T]) { - assert!(N != 0, "chunk size must be non-zero"); - let len_rounded_down = slice.len() / N * N; - // SAFETY: The rounded-down value is always the same or smaller than the - // original length, and thus must be in-bounds of the slice. - let (multiple_of_n, remainder) = unsafe { slice.split_at_mut_unchecked(len_rounded_down) }; - // SAFETY: We already panicked for zero, and ensured by construction - // that the length of the subslice is a multiple of N. - let array_slice = unsafe { slice_as_chunks_unchecked_mut(multiple_of_n) }; - (array_slice, remainder) -} - -/// Rust core `[T]::as_chunks_unchecked` vendored because of its 1.88 MSRV. -/// TODO(tarcieri): use upstream function when we bump MSRV -#[inline] -#[must_use] -#[track_caller] -#[allow(clippy::integer_division_remainder_used)] -unsafe fn slice_as_chunks_unchecked(slice: &[T]) -> &[[T; N]] { - // SAFETY: Caller must guarantee that `N` is nonzero and exactly divides the slice length - const { debug_assert!(N != 0) }; - debug_assert_eq!(slice.len() % N, 0); - let new_len = slice.len() / N; - - // SAFETY: We cast a slice of `new_len * N` elements into - // a slice of `new_len` many `N` elements chunks. - unsafe { slice::from_raw_parts(slice.as_ptr().cast(), new_len) } -} - -/// Rust core `[T]::as_chunks_unchecked_mut` vendored because of its 1.88 MSRV. -/// TODO(tarcieri): use upstream function when we bump MSRV -#[inline] -#[must_use] -#[track_caller] -#[allow(clippy::integer_division_remainder_used)] -unsafe fn slice_as_chunks_unchecked_mut(slice: &mut [T]) -> &mut [[T; N]] { - // SAFETY: Caller must guarantee that `N` is nonzero and exactly divides the slice length - const { debug_assert!(N != 0) }; - debug_assert_eq!(slice.len() % N, 0); - let new_len = slice.len() / N; - - // SAFETY: We cast a slice of `new_len * N` elements into - // a slice of `new_len` many `N` elements chunks. - unsafe { slice::from_raw_parts_mut(slice.as_mut_ptr().cast(), new_len) } -} - -#[cfg(test)] -mod tests { - // Spot check up to a given limit - const TEST_LIMIT: u8 = 128; - - macro_rules! masknz_test { - ( $($name:ident : $int:ident),+ ) => { - $( - #[test] - fn $name() { - assert_eq!(masknz!(0: $int), 0); - - // Test lower values - for i in 1..=$int::from(TEST_LIMIT) { - assert_eq!(masknz!(i: $int), $int::MAX); - } - - // Test upper values - for i in ($int::MAX - $int::from(TEST_LIMIT))..=$int::MAX { - assert_eq!(masknz!(i: $int), $int::MAX); - } - } - )+ - } - } - - // Ensure the macro works with any types we might use it with (we only use u8, u32, and u64) - masknz_test!( - masknz_u8: u8, - masknz_u16: u16, - masknz_u32: u32, - masknz_u64: u64, - masknz_u128: u128 - ); -} diff --git a/cmov/tests/core_impls.rs b/cmov/tests/core_impls.rs index ecff9414..92b68159 100644 --- a/cmov/tests/core_impls.rs +++ b/cmov/tests/core_impls.rs @@ -195,7 +195,7 @@ mod arrays { } #[test] - fn cmoveq_works() { + fn u8_cmoveq_works() { let mut o = 0u8; // Same contents. @@ -209,7 +209,7 @@ mod arrays { } #[test] - fn cmovne_works() { + fn u8_cmovne_works() { let mut o = 0u8; // Same contents. @@ -226,41 +226,76 @@ mod arrays { } mod slices { - use cmov::CmovEq; + use cmov::{Cmov, CmovEq}; + + const EXAMPLE_A: &[u8] = &[1u8, 2, 3]; + const EXAMPLE_B: &[u8] = &[1, 2, 4]; // different contents + const EXAMPLE_C: &[u8] = &[1u8, 2]; // different length #[test] - fn cmoveq_works() { - let example = [1u8, 2, 3].as_slice(); + fn u8_cmovnz_works() { + let mut x = [0u8; 3]; + x.as_mut_slice().cmovnz(EXAMPLE_A, 0); + assert_eq!(x, [0u8; 3]); + + for cond in 1..u8::MAX { + let mut x = [0u8; 3]; + x.as_mut_slice().cmovnz(EXAMPLE_A, cond); + assert_eq!(x, EXAMPLE_A); + } + } + + #[test] + fn u8_cmovz_works() { + let mut x = [0u8; 3]; + x.as_mut_slice().cmovz(EXAMPLE_A, 0); + assert_eq!(x, EXAMPLE_A); + + for cond in 1..u8::MAX { + let mut x = [0u8; 3]; + x.as_mut_slice().cmovz(EXAMPLE_A, cond); + assert_eq!(x, [0u8; 3]); + } + } + + #[test] + #[should_panic] + fn u8_cmovnz_length_mismatch_panics() { + let mut x = [0u8; 3]; + x.as_mut_slice().cmovnz(EXAMPLE_C, 1); + } + + #[test] + fn u8_cmoveq_works() { let mut o = 0u8; // Same slices. - example.cmoveq(example, 43, &mut o); + EXAMPLE_A.cmoveq(EXAMPLE_A, 43, &mut o); assert_eq!(o, 43); - // Different lengths. - example.cmoveq(&[1, 2], 44, &mut o); - assert_ne!(o, 44); - // Different contents. - example.cmoveq(&[1, 2, 4], 45, &mut o); + EXAMPLE_A.cmoveq(EXAMPLE_B, 45, &mut o); assert_ne!(o, 45); + + // Different lengths. + EXAMPLE_A.cmoveq(EXAMPLE_C, 44, &mut o); + assert_ne!(o, 44); } #[test] - fn cmovne_works() { - let example = [1u8, 2, 3].as_slice(); + fn u8_cmovne_works() { let mut o = 0u8; // Same slices. - example.cmovne(example, 43, &mut o); + EXAMPLE_A.cmovne(EXAMPLE_A, 43, &mut o); assert_ne!(o, 43); - // Different lengths. - example.cmovne(&[1, 2], 44, &mut o); - assert_eq!(o, 44); - // Different contents. - example.cmovne(&[1, 2, 4], 45, &mut o); + EXAMPLE_A.cmovne(EXAMPLE_B, 45, &mut o); assert_eq!(o, 45); + + // Different lengths. + EXAMPLE_A.cmovne(EXAMPLE_C, 44, &mut o); + assert_eq!(o, 44); } } diff --git a/ctutils/Cargo.toml b/ctutils/Cargo.toml index fda861d3..25fa1010 100644 --- a/ctutils/Cargo.toml +++ b/ctutils/Cargo.toml @@ -17,7 +17,7 @@ edition = "2024" rust-version = "1.85" [dependencies] -cmov = "0.4.3" +cmov = "0.5.0-pre" # optional dependencies subtle = { version = "2", optional = true, default-features = false }