From 210ab918a69a13d0900d23410a42d29cd3bf314d Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 04:08:20 +0200 Subject: [PATCH 1/2] crypto: Ed25519 signing and X25519 keys by a fixed-base comb Signing and Ed25519 key generation multiplied the base point by 256 doublings and complete additions with a select per bit; an X25519 public key was a second 255-step ladder from u = 9. The base point multiplication is now the radix-16 signed comb of Bernstein, Duif, Lange, Schwabe and Yang ("High-speed high-security signatures", 2012, section 4): - a scalar below 2^255 becomes 64 digits in [-8, 8] by wrapping arithmetic alone, held in a Workspace and erased when the multiplication returns; - the table, j 16^(2i) B for j = 1..=8 in 32 rows (30 KiB, affine), is built at compile time by the field's const fn arithmetic with one simultaneous inversion; - the odd digits' 32 entries are added, the sum doubled four times, and the even digits' 32 added: 64 mixed additions and 4 doublings; - each lookup reads all eight entries of its row and selects by mask, and a negative digit swaps y + x with y - x and negates 2dxy under a mask. An X25519 public key is u = (1 + y) / (1 - y) of the comb's [s]B (RFC 7748 section 4.1), one inversion. The fixed-schedule double-and-add is gone. Generated code: in [profile.toyos], the first version's recoding used += and -= on the secret digits, and the shipped profile's overflow checks compiled them to branches on those digits (jo on x86-64, cmp/b.ne on aarch64). They are wrapping operations now; the inspection after the change finds only loop counters, public lengths and the debug overlap check on stack addresses on both targets. New tests: the table rows against run-time addition, the recoding's digits and sum, the comb against the verification path's Straus multiplication (a different table and recoding), and X25519 public keys by the comb against the ladder. Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/ed25519.rs | 339 +++++++++++++++++++++++++++-------- lib/crypto/src/field25519.rs | 1 - lib/crypto/src/secret.rs | 3 +- lib/crypto/src/x25519.rs | 46 ++++- 4 files changed, 307 insertions(+), 82 deletions(-) diff --git a/lib/crypto/src/ed25519.rs b/lib/crypto/src/ed25519.rs index 04ed1339550..218b72d8575 100644 --- a/lib/crypto/src/ed25519.rs +++ b/lib/crypto/src/ed25519.rs @@ -2,14 +2,17 @@ //! rejects noncanonical encodings, negative zero, noncanonical S and small- //! order R or public keys. Verification's inputs are all public: it decodes, //! recodes and multiplies in variable time, through [`PublicScalar`] only. -//! Signing (feature `sign`) multiplies on a fixed schedule with complete -//! Edwards addition; neither tables nor branches depend on its secrets. +//! Signing (feature `sign`) and key generation multiply the base point by a +//! fixed-base comb on a fixed schedule: every table entry of a row is read +//! and the choice is masked, so neither branches nor addresses depend on +//! the secret scalar. use crate::{ Error, ct, field25519::{Loose, Multiply, Tight}, hash::Sha512, scalar25519::PublicScalar, + secret::Workspace, }; #[cfg(feature = "sign")] use crate::{Random, scalar25519::S, secret::Secret}; @@ -129,7 +132,6 @@ struct Niels { } impl Point { - #[cfg(feature = "sign")] fn identity() -> Self { Self { x: Tight::ZERO, @@ -138,7 +140,7 @@ impl Point { t: Tight::ZERO, } } - #[cfg(any(test, feature = "sign"))] + #[cfg(test)] fn base() -> Self { Self { x: B_X, @@ -191,11 +193,6 @@ impl Point { } .encode() } - /// RFC 8032 section 5.1.4's addition, complete on the curve. - #[cfg(feature = "sign")] - fn add(&self, q: &Self) -> Self { - self.add_cached(&q.cached()).point() - } fn cached(&self) -> Cached { Cached { y_plus_x: self.y.add(&self.x).carry(), @@ -241,26 +238,6 @@ impl Point { .double() .point() } - #[cfg(feature = "sign")] - fn select(a: &Self, b: &Self, bit: u64) -> Self { - Self { - x: Tight::select(&a.x, &b.x, bit), - y: Tight::select(&a.y, &b.y, bit), - z: Tight::select(&a.z, &b.z, bit), - t: Tight::select(&a.t, &b.t, bit), - } - } - /// scalar * self, on a fixed schedule. - #[cfg(feature = "sign")] - fn mul(&self, scalar: &[u8; 32]) -> Self { - let mut r = Self::identity(); - for i in (0..256).rev() { - let double = r.double(); - let added = double.add(self); - r = Self::select(&double, &added, u64::from((scalar[i / 8] >> (i % 8)) & 1)); - } - r - } } impl Completed { @@ -320,60 +297,199 @@ impl Projective { } impl Niels { - /// B, 3B, 5B, ..., normalised to affine with one inversion (Montgomery's - /// simultaneous-inversion trick). + /// B, 3B, 5B, ... const fn odd_multiples_of_b() -> [Niels; N] { - const fn add(p: &[Tight; 4], q: &[Tight; 4]) -> [Tight; 4] { - let [x1, y1, z1, t1] = p; - let [x2, y2, z2, t2] = q; - let a = y1.sub(x1).carry().times(&y2.sub(x2).carry()); - let b = y1.add(x1).carry().times(&y2.add(x2).carry()); - let c = t1.times(t2).times(&D2); - let zz = z1.times(z2); - let d = zz.add(&zz).carry(); - let e = b.sub(&a).carry(); - let f = d.sub(&c).carry(); - let g = d.add(&c).carry(); - let h = b.add(&a).carry(); - [e.times(&f), g.times(&h), f.times(&g), e.times(&h)] - } let base = [B_X, B_Y, Tight::ONE, B_T]; - let twice = add(&base, &base); + let twice = add_const(&base, &base); let mut points = [base; N]; let mut i = 1; while i < N { - points[i] = add(&points[i - 1], &twice); + points[i] = add_const(&points[i - 1], &twice); i += 1; } - // prefix[i] = z_0 z_1 ... z_i. - let mut prefix = [Tight::ONE; N]; - prefix[0] = points[0][2]; - let mut i = 1; - while i < N { - prefix[i] = prefix[i - 1].times(&points[i][2]); + normalize(&points) + } +} + +/// RFC 8032 section 5.1.4's addition of extended points, for constants. +const fn add_const(p: &[Tight; 4], q: &[Tight; 4]) -> [Tight; 4] { + let [x1, y1, z1, t1] = p; + let [x2, y2, z2, t2] = q; + let a = y1.sub(x1).carry().times(&y2.sub(x2).carry()); + let b = y1.add(x1).carry().times(&y2.add(x2).carry()); + let c = t1.times(t2).times(&D2); + let zz = z1.times(z2); + let d = zz.add(&zz).carry(); + let e = b.sub(&a).carry(); + let f = d.sub(&c).carry(); + let g = d.add(&c).carry(); + let h = b.add(&a).carry(); + [e.times(&f), g.times(&h), f.times(&g), e.times(&h)] +} + +/// Extended points in affine Niels form, with one inversion for all of them +/// (Montgomery's simultaneous-inversion trick). +const fn normalize(points: &[[Tight; 4]; N]) -> [Niels; N] { + // prefix[i] = z_0 z_1 ... z_i. + let mut prefix = [Tight::ONE; N]; + prefix[0] = points[0][2]; + let mut i = 1; + while i < N { + prefix[i] = prefix[i - 1].times(&points[i][2]); + i += 1; + } + let mut inverse = prefix[N - 1].invert(); + let mut table = [Niels::IDENTITY; N]; + let mut i = N; + while i > 0 { + i -= 1; + let z_inverse = if i == 0 { inverse } else { inverse.times(&prefix[i - 1]) }; + inverse = inverse.times(&points[i][2]); + let x = points[i][0].times(&z_inverse); + let y = points[i][1].times(&z_inverse); + table[i] = Niels { + y_plus_x: y.add(&x).carry(), + y_minus_x: y.sub(&x).carry(), + xy2d: x.times(&y).times(&D2), + }; + } + table +} + +const fn flatten(rows: &[[[Tight; 4]; 8]; 32]) -> [[Tight; 4]; 256] { + let mut flat = [rows[0][0]; 256]; + let mut n = 0; + while n < 256 { + flat[n] = rows[n / 8][n % 8]; + n += 1; + } + flat +} + +/// The fixed-base comb's table: row i holds j 16^(2i) B for j = 1..=8, in +/// affine form, built at compile time (Bernstein, Duif, Lange, Schwabe and +/// Yang, "High-speed high-security signatures", J. Cryptogr. Eng. 2012, +/// section 4). +static COMB: [[Niels; 8]; 32] = Niels::comb_table(); + +impl Niels { + const IDENTITY: Self = Self { + y_plus_x: Tight::ONE, + y_minus_x: Tight::ONE, + xy2d: Tight::ZERO, + }; + + const fn comb_table() -> [[Niels; 8]; 32] { + let base = [B_X, B_Y, Tight::ONE, B_T]; + let mut points = [[base; 8]; 32]; + let mut row = base; + let mut i = 0; + while i < 32 { + points[i][0] = row; + let mut j = 1; + while j < 8 { + points[i][j] = add_const(&points[i][j - 1], &row); + j += 1; + } + // The next row's base is 16^2 times this one's: eight doublings. + let mut k = 0; + while k < 8 { + row = add_const(&row, &row); + k += 1; + } i += 1; } - let mut inverse = prefix[N - 1].invert(); - let mut table = [Niels { - y_plus_x: Tight::ZERO, - y_minus_x: Tight::ZERO, - xy2d: Tight::ZERO, - }; N]; - let mut i = N; - while i > 0 { - i -= 1; - let z_inverse = if i == 0 { inverse } else { inverse.times(&prefix[i - 1]) }; - inverse = inverse.times(&points[i][2]); - let x = points[i][0].times(&z_inverse); - let y = points[i][1].times(&z_inverse); - table[i] = Niels { - y_plus_x: y.add(&x).carry(), - y_minus_x: y.sub(&x).carry(), - xy2d: x.times(&y).times(&D2), - }; + let flat = normalize::<256>(&flatten(&points)); + let mut table = [[Self::IDENTITY; 8]; 32]; + let mut n = 0; + while n < 256 { + table[n / 8][n % 8] = flat[n]; + n += 1; } table } + + /// row[|d| - 1], negated when d < 0, or the identity when d = 0. Every + /// entry is read and the choice is masked, so neither the time nor the + /// addresses depend on d. + fn select(row: &[Niels; 8], d: i8) -> Niels { + let negative = u64::from((d as u8) >> 7); + let magnitude = ((d ^ (0i8.wrapping_sub(negative as i8))).wrapping_add(negative as i8)) as u64; + let mut chosen = Self::IDENTITY; + for (j, entry) in row.iter().enumerate() { + let take = 1 ^ ct::nonzero(magnitude ^ (j as u64 + 1)); + chosen = Self { + y_plus_x: Tight::select(&chosen.y_plus_x, &entry.y_plus_x, take), + y_minus_x: Tight::select(&chosen.y_minus_x, &entry.y_minus_x, take), + xy2d: Tight::select(&chosen.xy2d, &entry.xy2d, take), + }; + } + // -(x, y) = (-x, y): exchange y + x with y - x and negate 2dxy. + let mask = ct::mask(negative); + let (mut y_plus_x, mut y_minus_x) = (chosen.y_plus_x, chosen.y_minus_x); + Tight::swap(&mut y_plus_x, &mut y_minus_x, mask); + Self { + y_plus_x, + y_minus_x, + xy2d: Tight::select(&chosen.xy2d, &chosen.xy2d.neg().carry(), negative), + } + } +} + +/// a = sum of e[i] 16^i with every e[i] in [-8, 8], for a below 2^255, by +/// arithmetic alone: each nibble is moved into [-8, 8) and the rest carried +/// up, and the top digit, at most 7 plus a carry, stays at most 8. +fn recode(a: &[u8; 32], e: &mut [i8; 64]) { + for i in 0..32 { + e[2 * i] = (a[i] & 15) as i8; + e[2 * i + 1] = (a[i] >> 4) as i8; + } + // Wrapping operations: every value stays within [-8, 16], and the shipped + // profile's overflow checks would branch on the secret digits. + let mut carry = 0i8; + for digit in &mut e[..63] { + *digit = digit.wrapping_add(carry); + carry = digit.wrapping_add(8) >> 4; + *digit = digit.wrapping_sub(carry << 4); + } + e[63] = e[63].wrapping_add(carry); +} + +/// The X25519 public key of a clamped scalar s: u = (1 + y) / (1 - y) of the +/// Edwards point [s]B, the birational map between the curves (RFC 7748 +/// section 4.1), which takes B to the base point u = 9. +pub(crate) fn x25519_public(s: &[u8; 32]) -> [u8; 32] { + let p = base_mul(s); + // A clamped s is a multiple of 8 below 2^255 with bit 254 set, so it is + // no multiple of L and [s]B is not the identity: Z - Y is nonzero. + p.z.add(&p.y).mul(&p.z.sub(&p.y).carry().invert()).bytes() +} + +/// [a]B for a 32-byte little-endian a below 2^255, on a fixed schedule: the +/// 64 signed radix-16 digits of a, 64 masked table reads and mixed +/// additions, and 4 doublings. The digits are the operation's secret state, +/// erased when it returns. +fn base_mul(a: &[u8; 32]) -> Point { + let mut digits = Workspace([0i8; 64]); + let e = &mut digits.0; + recode(a, e); + let mut r = Point::identity(); + for i in 0..32 { + r = r.add_niels(&Niels::select(&COMB[i], e[2 * i + 1])).point(); + } + let mut p = Projective { + x: r.x, + y: r.y, + z: r.z, + }; + for _ in 0..3 { + p = p.double().projective(); + } + r = p.double().point(); + for i in 0..32 { + r = r.add_niels(&Niels::select(&COMB[i], e[2 * i])).point(); + } + r } /// [s]B - [k]A by one doubling chain over both scalars' NAFs (Straus's @@ -472,7 +588,7 @@ impl SigningKey { let scalar = S::reduce32(expanded.0[..32].try_into().unwrap()); let prefix = Secret(expanded.0[32..].try_into().unwrap()); // B has order L, so the reduced scalar gives the clamped one's point. - let point = Point::base().mul(&Secret(scalar.bytes()).0); + let point = base_mul(&Secret(scalar.bytes()).0); let public = PublicKey { encoded: point.encode(), point, @@ -494,7 +610,7 @@ impl SigningKey { let digest = Secret(h.finalize()); let r = S::reduce(&digest.0); let r_bytes = Secret(r.bytes()); - let encoded = Point::base().mul(&r_bytes.0).encode(); + let encoded = base_mul(&r_bytes.0).encode(); let mut h = Sha512::new(); h.update(&encoded)?; h.update(self.public.as_bytes())?; @@ -597,6 +713,83 @@ mod tests { assert_eq!(key.public_key().verify(b"forged", &signature), Err(Error::InvalidSignature)); } + /// The comb's rows are j 16^(2i) B, against run-time additions. + #[test] + fn comb_table_holds_its_multiples() { + let mut row = Point::base(); + for (i, entries) in COMB.iter().enumerate() { + let mut p = row; + for (j, entry) in entries.iter().enumerate() { + if j > 0 { + p = p.add_cached(&row.cached()).point(); + } + let zi = p.z.invert(); + let (x, y) = (p.x.mul(&zi), p.y.mul(&zi)); + assert_eq!(entry.y_plus_x.bytes(), y.add(&x).carry().bytes(), "row {i}, entry {j}"); + assert_eq!(entry.y_minus_x.bytes(), y.sub(&x).carry().bytes(), "row {i}, entry {j}"); + assert_eq!(entry.xy2d.bytes(), x.mul(&y).mul(&D2).bytes(), "row {i}, entry {j}"); + } + for _ in 0..8 { + row = row.double(); + } + } + } + + fn scalars() -> std::vec::Vec<[u8; 32]> { + let mut out = std::vec![[0; 32], [0x88; 32], [0x77; 32], [0xff; 32]]; + for a in &mut out { + a[31] &= 0x7f; + } + let mut seed = 0x2545f4914f6cdd1du64; + for _ in 0..60 { + let mut a = [0u8; 32]; + for byte in &mut a { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + *byte = seed as u8; + } + a[31] &= 0x7f; + out.push(a); + } + out + } + + /// The digits lie in [-8, 8] and recompose the scalar, up to 2^255 - 1. + #[test] + fn radix_16_digits_recompose_the_scalar() { + use num_bigint::BigInt; + for a in scalars() { + let mut e = [0i8; 64]; + recode(&a, &mut e); + let mut sum = BigInt::from(0); + for (i, &d) in e.iter().enumerate() { + assert!((-8..=8).contains(&d)); + sum += BigInt::from(d) << (4 * i); + } + assert_eq!(sum, BigInt::from_bytes_le(num_bigint::Sign::Plus, &a)); + } + } + + /// The comb agrees with the verification path's table and recoding, a + /// different route to [s]B, for scalars below L. + #[test] + fn the_comb_agrees_with_straus() { + let zero = PublicScalar::canonical(&[0; 32]).unwrap(); + for a in scalars() { + let s = PublicScalar::reduce(&{ + let mut wide = [0; 64]; + wide[..32].copy_from_slice(&a); + wide + }); + let reduced = s.bytes(); + let comb = base_mul(&reduced); + let straus = verification_point(&s, &zero, &Point::base()); + let comb = Projective { x: comb.x, y: comb.y, z: comb.z }; + assert_eq!(comb.encode(), straus.encode()); + } + } + /// The static table is B's odd multiples, against repeated addition. #[test] fn base_table_holds_the_odd_multiples_of_b() { diff --git a/lib/crypto/src/field25519.rs b/lib/crypto/src/field25519.rs index f0a7d7dbfdb..e878ef878ff 100644 --- a/lib/crypto/src/field25519.rs +++ b/lib/crypto/src/field25519.rs @@ -183,7 +183,6 @@ impl Tight { z_250_0.square_times(2).times(self) } - #[cfg(feature = "sign")] pub(crate) fn select(a: &Self, b: &Self, bit: u64) -> Self { let m = ct::mask(bit); let (a, b) = (&a.0, &b.0); diff --git a/lib/crypto/src/secret.rs b/lib/crypto/src/secret.rs index 6b39aeac33f..dcfffd5d9a3 100644 --- a/lib/crypto/src/secret.rs +++ b/lib/crypto/src/secret.rs @@ -14,7 +14,7 @@ //! - **Erased: workspaces.** A secret operation's explicitly managed state is //! a [`Workspace`], dropped when the operation returns, on every path out of //! it, early returns and `?` included. The X25519 ladder's (x2, z2, x3, z3) -//! is one. A workspace erases its own storage; the copies the computation +//! and the Ed25519 comb's signed digits are workspaces. A workspace erases its own storage; the copies the computation //! works on in registers and other slots are not erased. //! - **Erased per value.** Ed25519's secret scalars (`scalar25519::S`) are //! owners, and their reduction erases its workspaces; ML-KEM polynomials @@ -67,6 +67,7 @@ pub(crate) fn wipe64(words: &mut [u64]) { pub(crate) unsafe trait Zeroable: Copy {} // Integers and arrays of zeroable elements are valid at zero. unsafe impl Zeroable for u8 {} +unsafe impl Zeroable for i8 {} unsafe impl Zeroable for u32 {} unsafe impl Zeroable for u64 {} unsafe impl Zeroable for u128 {} diff --git a/lib/crypto/src/x25519.rs b/lib/crypto/src/x25519.rs index f49534858ad..8abe7e9289c 100644 --- a/lib/crypto/src/x25519.rs +++ b/lib/crypto/src/x25519.rs @@ -1,5 +1,7 @@ -//! RFC 7748 X25519. Secret multiplication uses the 255-step Montgomery ladder -//! with masked swaps and fixed-schedule inversion. Public inputs accept the +//! RFC 7748 X25519. Agreement uses the 255-step Montgomery ladder with masked +//! swaps and fixed-schedule inversion; a public key is the Edwards base +//! point's fixed-base comb multiple, mapped to u. Both run on a fixed +//! schedule. Public inputs accept the //! RFC's noncanonical field encodings. All-zero shared secrets are refused. //! The caller supplies entropy; this module never queries the CPU for it. @@ -29,11 +31,7 @@ impl SecretKey { Ok(Self::from_bytes(b.0)) } pub fn public_key(&self) -> PublicKey { - let mut base = [0; 32]; - base[0] = 9; - let mut out = [0; 32]; - multiply(&self.0.0, &base, &mut out); - PublicKey(out) + PublicKey(crate::ed25519::x25519_public(&self.0.0)) } pub fn agree(self, peer: &PublicKey) -> Result { let mut result = Secret([0; 32]); @@ -78,3 +76,37 @@ fn multiply(scalar: &[u8; 32], point: &[u8; 32], out: &mut [u8; 32]) { let shared = Workspace(x2.mul(&z2.invert())); *out = shared.0.bytes(); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The public key by the Edwards comb is the ladder's multiple of u = 9, + /// for clamped scalars of every kind of digit. + #[test] + fn public_keys_by_the_comb_are_the_ladders() { + let mut base = [0; 32]; + base[0] = 9; + let mut seed = 0x9e3779b97f4a7c15u64; + for case in 0..64 { + let mut bytes = [0u8; 32]; + for byte in &mut bytes { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + *byte = seed as u8; + } + match case { + 0 => bytes = [0; 32], + 1 => bytes = [0xff; 32], + 2 => bytes = [0x88; 32], + 3 => bytes = [0x77; 32], + _ => {} + } + let key = SecretKey::from_bytes(bytes); + let mut ladder = [0; 32]; + multiply(&key.0.0, &base, &mut ladder); + assert_eq!(key.public_key().0, ladder, "case {case}"); + } + } +} From 264138fdb05626aab754b5d197c93ab1b607750a Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 22 Oct 2026 04:45:59 +0200 Subject: [PATCH 2/2] crypto: rewrap two comb-era module comments Co-Authored-By: Claude Opus 5.5 --- lib/crypto/src/secret.rs | 5 +++-- lib/crypto/src/x25519.rs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/crypto/src/secret.rs b/lib/crypto/src/secret.rs index dcfffd5d9a3..b72f901dfa5 100644 --- a/lib/crypto/src/secret.rs +++ b/lib/crypto/src/secret.rs @@ -14,8 +14,9 @@ //! - **Erased: workspaces.** A secret operation's explicitly managed state is //! a [`Workspace`], dropped when the operation returns, on every path out of //! it, early returns and `?` included. The X25519 ladder's (x2, z2, x3, z3) -//! and the Ed25519 comb's signed digits are workspaces. A workspace erases its own storage; the copies the computation -//! works on in registers and other slots are not erased. +//! and the Ed25519 comb's signed digits are workspaces. A workspace erases +//! its own storage; the copies the computation works on in registers and +//! other slots are not erased. //! - **Erased per value.** Ed25519's secret scalars (`scalar25519::S`) are //! owners, and their reduction erases its workspaces; ML-KEM polynomials //! (`mlkem::Poly`) erase every temporary on drop. diff --git a/lib/crypto/src/x25519.rs b/lib/crypto/src/x25519.rs index 8abe7e9289c..542830feeb1 100644 --- a/lib/crypto/src/x25519.rs +++ b/lib/crypto/src/x25519.rs @@ -1,9 +1,9 @@ //! RFC 7748 X25519. Agreement uses the 255-step Montgomery ladder with masked //! swaps and fixed-schedule inversion; a public key is the Edwards base //! point's fixed-base comb multiple, mapped to u. Both run on a fixed -//! schedule. Public inputs accept the -//! RFC's noncanonical field encodings. All-zero shared secrets are refused. -//! The caller supplies entropy; this module never queries the CPU for it. +//! schedule. Public inputs accept the RFC's noncanonical field encodings. +//! All-zero shared secrets are refused. The caller supplies entropy; this +//! module never queries the CPU for it. use crate::{ Error, Random, ct,