From dd5bd108d6985491acb3de25497fb082a29c9fb7 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Tue, 27 May 2025 22:09:49 -0600 Subject: [PATCH] curve: add `EdwardsPoint::compress_batch` and inherent `::random` (#759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * curve: add `EdwardsPoint::compress_batch` and `::random` We've had various requests to implement batch point compression for `EdwardsPoint`, e.g. #705. We can leverage `FieldElement::batch_invert` to implement it, which results in a fairly significant speedup. The name `EdwardsPoint::compress_batch` has been chosen to match `RistrettoPoint::double_and_compress_batch`. For benchmarking, randomized `EdwardsPoint`s have been used. To obtain these, an inherent `EdwardsPoint::random` has been extracted from the existing `Group::random` implementation, which uses rejection sampling. `Group::random` has been updated to call the inherent `EdwardsPoint::random`. This avoids a `group` dependency just to run the batch compression benchmarks. The following benchmark results have been obtained: edwards benches/EdwardsPoint compression time: [3.5029 µs 3.5098 µs 3.5171 µs] edwards benches/Batch EdwardsPoint compression/1 time: [3.6698 µs 3.6758 µs 3.6817 µs] edwards benches/Batch EdwardsPoint compression/2 time: [3.8410 µs 3.8461 µs 3.8516 µs] edwards benches/Batch EdwardsPoint compression/4 time: [4.1534 µs 4.1961 µs 4.2558 µs] edwards benches/Batch EdwardsPoint compression/8 time: [4.8466 µs 4.8533 µs 4.8600 µs] edwards benches/Batch EdwardsPoint compression/16 time: [6.1216 µs 6.1315 µs 6.1410 µs] As you can see, it affords a fairly significant speedup, batch compressing 16 points in less time than the standard point compression algorithm would take to compress 2 in a row. --- curve25519-dalek/benches/dalek_benchmarks.rs | 18 ++++ curve25519-dalek/src/edwards.rs | 98 +++++++++++++++++--- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/curve25519-dalek/benches/dalek_benchmarks.rs b/curve25519-dalek/benches/dalek_benchmarks.rs index 62a6280..0b2653e 100644 --- a/curve25519-dalek/benches/dalek_benchmarks.rs +++ b/curve25519-dalek/benches/dalek_benchmarks.rs @@ -22,6 +22,22 @@ mod edwards_benches { c.bench_function("EdwardsPoint compression", move |b| b.iter(|| B.compress())); } + #[cfg(feature = "alloc")] + fn compress_batch(c: &mut BenchmarkGroup) { + for batch_size in BATCH_SIZES { + c.bench_with_input( + BenchmarkId::new("Batch EdwardsPoint compression", batch_size), + &batch_size, + |b, &size| { + let mut rng = OsRng; + let points: Vec = + (0..size).map(|_| EdwardsPoint::random(&mut rng)).collect(); + b.iter(|| EdwardsPoint::compress_batch(&points)); + }, + ); + } + } + fn decompress(c: &mut BenchmarkGroup) { let B_comp = &constants::ED25519_BASEPOINT_COMPRESSED; c.bench_function("EdwardsPoint decompression", move |b| { @@ -62,6 +78,8 @@ mod edwards_benches { compress(&mut g); decompress(&mut g); + #[cfg(feature = "alloc")] + compress_batch(&mut g); consttime_fixed_base_scalar_mul(&mut g); consttime_variable_base_scalar_mul(&mut g); vartime_double_base_scalar_mul(&mut g); diff --git a/curve25519-dalek/src/edwards.rs b/curve25519-dalek/src/edwards.rs index d6672bd..d740d63 100644 --- a/curve25519-dalek/src/edwards.rs +++ b/curve25519-dalek/src/edwards.rs @@ -93,6 +93,7 @@ // affine and projective cakes and eat both of them too. #![allow(non_snake_case)] +use cfg_if::cfg_if; use core::array::TryFromSliceError; use core::borrow::Borrow; use core::fmt::Debug; @@ -101,8 +102,6 @@ use core::ops::{Add, Neg, Sub}; use core::ops::{AddAssign, SubAssign}; use core::ops::{Mul, MulAssign}; -use cfg_if::cfg_if; - #[cfg(feature = "digest")] use digest::{generic_array::typenum::U64, Digest}; @@ -112,7 +111,7 @@ use { subtle::CtOption, }; -#[cfg(feature = "group")] +#[cfg(any(test, feature = "rand_core"))] use rand_core::RngCore; use subtle::Choice; @@ -151,6 +150,8 @@ use crate::traits::{Identity, IsIdentity}; use crate::traits::MultiscalarMul; #[cfg(feature = "alloc")] use crate::traits::{VartimeMultiscalarMul, VartimePrecomputedMultiscalarMul}; +#[cfg(feature = "alloc")] +use alloc::vec::Vec; // ------------------------------------------------------------------------ // Compressed points @@ -567,9 +568,31 @@ impl EdwardsPoint { let recip = self.Z.invert(); let x = &self.X * &recip; let y = &self.Y * &recip; - let mut s: [u8; 32]; + Self::compress_affine(x, y) + } - s = y.as_bytes(); + /// Compress several `EdwardsPoint`s into `CompressedEdwardsY` format, using a batch inversion + /// for a significant speedup. + #[cfg(feature = "alloc")] + pub fn compress_batch(inputs: &[EdwardsPoint]) -> Vec { + let mut zs = inputs.iter().map(|input| input.Z).collect::>(); + FieldElement::batch_invert(&mut zs); + + inputs + .iter() + .zip(&zs) + .map(|(input, recip)| { + let x = &input.X * recip; + let y = &input.Y * recip; + Self::compress_affine(x, y) + }) + .collect() + } + + /// Compress affine Edwards coordinates into `CompressedEdwardsY` format. + #[inline] + fn compress_affine(x: FieldElement, y: FieldElement) -> CompressedEdwardsY { + let mut s = y.as_bytes(); s[31] ^= x.is_negative().unwrap_u8() << 7; CompressedEdwardsY(s) } @@ -605,6 +628,33 @@ impl EdwardsPoint { .expect("Montgomery conversion to Edwards point in Elligator failed") .mul_by_cofactor() } + + /// Return an `EdwardsPoint` chosen uniformly at random using a user-provided RNG. + /// + /// # Inputs + /// + /// * `rng`: any RNG which implements `RngCore` + /// + /// # Returns + /// + /// A random `EdwardsPoint`. + /// + /// # Implementation + /// + /// Uses rejection sampling, generating a random `CompressedEdwardsY` and then attempting point + /// decompression, rejecting invalid points. + #[cfg(any(test, feature = "rand_core"))] + pub fn random(mut rng: impl RngCore) -> Self { + let mut repr = CompressedEdwardsY([0u8; 32]); + loop { + rng.fill_bytes(&mut repr.0); + if let Some(p) = repr.decompress() { + if !IsIdentity::is_identity(&p) { + break p; + } + } + } + } } // ------------------------------------------------------------------------ @@ -1291,16 +1341,9 @@ impl Debug for EdwardsPoint { impl group::Group for EdwardsPoint { type Scalar = Scalar; - fn random(mut rng: impl RngCore) -> Self { - let mut repr = CompressedEdwardsY([0u8; 32]); - loop { - rng.fill_bytes(&mut repr.0); - if let Some(p) = repr.decompress() { - if !IsIdentity::is_identity(&p) { - break p; - } - } - } + fn random(rng: impl RngCore) -> Self { + // Call the inherent `pub fn random` defined above + Self::random(rng) } fn identity() -> Self { @@ -2019,6 +2062,31 @@ mod test { EdwardsPoint::identity().compress(), CompressedEdwardsY::identity() ); + + #[cfg(feature = "alloc")] + { + let compressed = EdwardsPoint::compress_batch(&[EdwardsPoint::identity()]); + assert_eq!(&compressed, &[CompressedEdwardsY::identity()]); + } + } + + #[cfg(feature = "alloc")] + #[test] + fn compress_batch() { + let mut rng = rand::thread_rng(); + + // TODO(tarcieri): proptests? + // Make some points deterministically then randomly + let mut points = (1u64..16) + .map(|n| constants::ED25519_BASEPOINT_POINT * Scalar::from(n)) + .collect::>(); + points.extend(core::iter::repeat_with(|| EdwardsPoint::random(&mut rng)).take(100)); + let compressed = EdwardsPoint::compress_batch(&points); + + // Check that the batch-compressed points match the individually compressed ones + for (point, compressed) in points.iter().zip(&compressed) { + assert_eq!(&point.compress(), compressed); + } } #[test]