From 4e5d288ed7d609e53f1e6100773dea037a5c8a49 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 20 Aug 2026 12:31:09 -0500 Subject: [PATCH 01/26] Some incremental progress towards 25519 --- Cargo.toml | 2 + crypto/core/src/traits.rs | 15 + crypto/ed/Cargo.toml | 10 + crypto/ed/src/curve25519.rs | 613 ++++++++++++++++++++++++++++++++ crypto/ed/src/lib.rs | 22 ++ crypto/ed/src/x25519.rs | 178 ++++++++++ crypto/ed/tests/x25519_tests.rs | 130 +++++++ 7 files changed, 970 insertions(+) create mode 100644 crypto/ed/Cargo.toml create mode 100644 crypto/ed/src/curve25519.rs create mode 100644 crypto/ed/src/lib.rs create mode 100644 crypto/ed/src/x25519.rs create mode 100644 crypto/ed/tests/x25519_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 11e1f13cc..76a8f63ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ bouncycastle-base64 = { path = "./crypto/base64" } bouncycastle-cipher = { path = "./crypto/cipher" } bouncycastle-core = { path = "crypto/core" } bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" } +bouncycastle-ed = { path = "crypto/ed" } bouncycastle-factory = { path = "./crypto/factory" } bouncycastle-hex = { path = "./crypto/hex" } bouncycastle-hkdf = { path = "./crypto/hkdf" } @@ -52,6 +53,7 @@ bouncycastle-ascon.workspace = true bouncycastle-base64.workspace = true bouncycastle-cipher.workspace = true bouncycastle-core.workspace = true +bouncycastle-edcurves.workspace = true bouncycastle-factory.workspace = true bouncycastle-hex.workspace = true bouncycastle-hkdf.workspace = true diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 4a68f9a45..01c4d487d 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1084,6 +1084,21 @@ pub trait KEMPublicKey: fn from_bytes(bytes: &[u8]) -> Result; } +/// A Key Agreement algorithm, often called a Diffie-Hellman algorithm. +/// The core function is `key_agreement(&pk, &sk) -> OutputType` which acts on one public key and one private key. +/// The return type of `key_agreement` will depend on the specific algorithm -- often it will be a `[u8]`, but it +/// may be a struct, such as a public key. +pub trait KeyAgreement { + /// Perform a key agreement + fn key_agreement(pk: &PK, sk: SK) -> OutputType; +} + +/// A public key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "pk". +pub trait KeyAgreementPublicKey {} + +/// A private key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "sk" (for "secret key"). +pub trait KeyAgreementPrivateKey {} + /// A Message Authentication Code algorithm is a keyed hash function that behaves somewhat like a symmetric signature function. /// A MAC algorithm takes in a key and some data, and produces a MAC (message authentication code) that /// can be used to verify the integrity of data. diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml new file mode 100644 index 000000000..4d879f0db --- /dev/null +++ b/crypto/ed/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "bouncycastle-ed" +edition.workspace = true +version.workspace = true + +[dependencies] +bouncycastle-utils.workspace = true + +[dev-dependencies] +bouncycastle-hex.workspace = true \ No newline at end of file diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs new file mode 100644 index 000000000..f7e8bb7eb --- /dev/null +++ b/crypto/ed/src/curve25519.rs @@ -0,0 +1,613 @@ +use bouncycastle_utils::ct::Condition; +use math::modular; + +const M51: i64 = (1 << 51) - 1; + +pub const P32: [u32; 8] = [ + 0xFFFFFFED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, +]; +pub const P64: [u64; 4] = + [0xFFFFFFFFFFFFFFED, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7FFFFFFFFFFFFFFF]; + +// TODO Add magnitude, normalized fields to CoordField in debug mode and track validity through all operations +#[derive(Clone, Copy, Debug, Default)] +#[must_use] +#[repr(transparent)] +// TODO -- are all these annotations really necessary? Doesn't this contain secrets? Don't we want to block Debug? +// TODO -- should this be `[Secret; 5]` ? +// TODO -- why is this pub and not pub(crate)? -- same question for the fn's in the impl. +pub struct CoordField([i64; 5]); + +// TODO -- all of these need at least simple unit tests +impl CoordField { + /// Add another [`CoordField`] to this one, component-wise. + pub const fn add(&self, rhs: &Self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + let [y0, y1, y2, y3, y4] = rhs.0; + Self([x0 + y0, x1 + y1, x2 + y2, x3 + y3, x4 + y4]) + } + + // TODO -- needs a docstring + pub const fn apm(&self, rhs: &Self) -> (Self, Self) { + let [x0, x1, x2, x3, x4] = self.0; + let [y0, y1, y2, y3, y4] = rhs.0; + ( + Self([x0 + y0, x1 + y1, x2 + y2, x3 + y3, x4 + y4]), + Self([x0 - y0, x1 - y1, x2 - y2, x3 - y3, x4 - y4]), + ) + } + + // TODO -- why are we cloning? + pub const fn add_one(&self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + Self([x0 + 1, x1, x2, x3, x4]) + } + + // TODO -- why are we returning the same ref we were passed? + pub fn add_one_mut(&mut self) -> &mut Self { + self.0[0] += 1; + self + } + + // TODO Requires normalized inputs + // TODO -- what uses this? + pub const fn are_equal(lhs: &Self, rhs: &Self) -> Condition { + let [x0, x1, x2, x3, x4] = lhs.0; + let [y0, y1, y2, y3, y4] = rhs.0; + Condition::::is_zero((x0 ^ y0) | (x1 ^ y1) | (x2 ^ y2) | (x3 ^ y3) | (x4 ^ y4)) + } + + // TODO Requires normalized inputs + // TODO -- what uses this? + pub const fn are_equal_var(lhs: &Self, rhs: &Self) -> bool { + Self::are_equal(lhs, rhs).to_bool_var() + } + + // TODO -- needs a docstring + pub const fn carry(&self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + let c1 = x0 >> 51; + let c2 = x1 >> 51; + let c3 = x2 >> 51; + let c4 = x3 >> 51; + let c5 = x4 >> 51; + let z0 = (x0 & M51) + c5 * 19; + let z1 = (x1 & M51) + c1; + let z2 = (x2 & M51) + c2; + let z3 = (x3 & M51) + c3; + let z4 = (x4 & M51) + c4; + Self([z0, z1, z2, z3, z4]) + } + + // TODO -- why are we returning the same ref we were passed? + pub fn carry_mut(&mut self) -> &mut Self { + *self = self.carry(); + self + } + + /// Move the coordinates of x into self if `cond` is true. + pub fn cmov(&mut self, cond: Condition, x: &Self) -> &mut Self { + // *self = Self::cselect(cond, x, self); + cond.mov(x.0[0], &mut self.0[0]); + cond.mov(x.0[1], &mut self.0[1]); + cond.mov(x.0[2], &mut self.0[2]); + cond.mov(x.0[3], &mut self.0[3]); + cond.mov(x.0[4], &mut self.0[4]); + self + } + + /// Negate the components of self if `cond` is true. + pub fn cnegate(&mut self, cond: Condition) -> &mut Self { + self.0[0] = cond.negate(self.0[0]); + self.0[1] = cond.negate(self.0[1]); + self.0[2] = cond.negate(self.0[2]); + self.0[3] = cond.negate(self.0[3]); + self.0[4] = cond.negate(self.0[4]); + self + } + + /// Returns a clone of lhs if `cond` is true, else returns rhs. + pub fn cselect(cond: Condition, lhs: &Self, rhs: &Self) -> Self { + let [x0, x1, x2, x3, x4] = lhs.0; + let [y0, y1, y2, y3, y4] = rhs.0; + let z0 = cond.select(x0, y0); + let z1 = cond.select(x1, y1); + let z2 = cond.select(x2, y2); + let z3 = cond.select(x3, y3); + let z4 = cond.select(x4, y4); + Self([z0, z1, z2, z3, z4]) + } + + /// Swap the values of lhs and rhs if `cond` is true. + pub fn cswap(cond: Condition, lhs: &mut Self, rhs: &mut Self) { + (lhs.0[0], rhs.0[0]) = cond.swap(lhs.0[0], rhs.0[0]); + (lhs.0[1], rhs.0[1]) = cond.swap(lhs.0[1], rhs.0[1]); + (lhs.0[2], rhs.0[2]) = cond.swap(lhs.0[2], rhs.0[2]); + (lhs.0[3], rhs.0[3]) = cond.swap(lhs.0[3], rhs.0[3]); + (lhs.0[4], rhs.0[4]) = cond.swap(lhs.0[4], rhs.0[4]); + } + + // TODO -- decide how the normalization precondition is handled across this codec group. + // This comment was generated by Claude and left here to be considered unce I have + // a better understanding of the code. + // + // `decode_255` deliberately returns an un-normalized value: RFC 7748 Section 5 requires that + // non-canonical u-coordinates (u >= p) be accepted rather than rejected. But `encode_255`, + // `encode64_255`, `are_equal`, `are_equal_var` and `parity` all *require* a normalized input, + // enforced today only by a documented precondition that every call site has to remember. All of + // them currently do (`scalar_mult` and `encode_u_coordinate` in x25519.rs, `inv` below), so this + // is a latent footgun rather than a live bug -- but the asymmetry is ugly: decoding asks nothing + // of the caller, encoding and comparison ask for a step they can silently omit. `parity` is the + // sharpest edge: p is odd, so a value in [p, 2^255) reports the parity of v rather than v - p. + // + // Options: + // 1. Fold `self.normalize()` into `encode_255` and drop it from its call sites. Free -- since + // normalize is idempotent this moves work rather than adding it -- and encode is outside the + // ladder loop, so nothing hot is touched. Does not help `are_equal`/`parity`, and folding it + // into `are_equal` WOULD cost, because that one can land in hot code. + // 2. Typestate: `normalize(self) -> Normalized`, with `encode_255`, `are_equal` and `parity` + // defined only on `Normalized`. Compile-time enforcement, which is what + // QUALITY_AND_STYLE.md asks for over runtime discipline, and it covers the whole group at + // once. Bigger change, and it wants the full list of consumers -- Ed25519 point compression + // will add more -- so it probably waits for that (see the deferral note atop x25519.rs). + // 3. Debug-only tracking, i.e. the magnitude/normalized fields already proposed in the TODO at + // the top of this file. Catches violations in tests without changing the API, but proves + // nothing in release and puts a debug-only field on a `Copy` type. + // + // None of these remove the precondition outright: `normalize` itself needs limbs inside the + // magnitude bound that `reduce`'s carry chain assumes (`t5 * 19 + x0` must not overflow i64). + // That residual bound is exactly what option 3 would track. + + /// Encodes a field element as a 255-bit value in 32 little-endian bytes; the inverse of [`Self::decode_255`]. + /// Bit 255 (the most significant bit of the last byte) is always written as zero. + /// + /// Callers that need that bit to carry something else -- the + /// sign of x in an Ed25519 point encoding (Section 5.1.3 of RFC 8032) -- must OR it in after. + /// + /// Note: Requires normalized self -- see [`Self::encode64_255`]; only a normalized value encodes to the + /// canonical byte string for its residue class. + /// + /// Input: self, normalized; a 32-byte output buffer. + /// Output: The value written to `bytes` as a 255-bit little-endian integer. + pub fn encode_255(&self, bytes: &mut [u8; 32]) { + let [w0, w1, w2, w3] = self.encode64_255(); + let (a, b, c, d) = (w0.to_le_bytes(), w1.to_le_bytes(), w2.to_le_bytes(), w3.to_le_bytes()); + // Each word fills a fixed 8-byte window, least significant word first. Every index is a + // constant into a fixed-size array, so an out-of-bounds index would be a compile error (the + // deny-by-default `unconditional_panic` lint) and no runtime bounds check survives. + *bytes = [ + a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], // + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], // + c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], // + d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], + ]; + } + + /// Packs the five 51-bit limbs into a 255-bit little-endian integer held as four 64-bit words, + /// each word taking a whole limb plus the low bits of the next. Inverse of + /// [`Self::decode64_255`]. Bit 255 of the result is always zero, since limb `x4` lands in bits + /// 204..254. + /// + /// Note: Requires normalized self: every limb must be in [0, 2^51) and the value reduced mod p. + /// The splices below are `|`, not `+`, so a limb with bits at or above 51 collides with the next + /// limb's bits instead of carrying into them, and the result is garbage. (The `x1 << 51` etc. + /// deliberately shift bits off the top of the i64 -- those are the bits the previous word + /// already carried.) + /// + /// Input: self, normalized. + /// Output: The value as four little-endian 64-bit words, least significant word first. + const fn encode64_255(&self) -> [u64; 4] { + let [x0, x1, x2, x3, x4] = self.0; + let z0 = (x0 | x1 << 51) as u64; + let z1 = (x1 >> 13 | x2 << 38) as u64; + let z2 = (x2 >> 26 | x3 << 25) as u64; + let z3 = (x3 >> 39 | x4 << 12) as u64; + [z0, z1, z2, z3] + } + + /// Decodes a 32-byte little-endian encoding of a field element. Bit 255 (the most significant + /// bit of the last byte) is masked off and ignored, since it is not part of a 255-bit field + /// element. Callers own the meaning of that bit: for X25519 it is unused and masking it is + /// required (see [`crate::x25519::decode_u_coordinate`]), for Ed25519 it carries the sign of x + /// (Section 5.1.3 of RFC 8032) and must be stripped off and handled separately. + /// Input: A 32-byte little-endian integer. + /// Output: The low 255 bits of that integer as a [`CoordField`]. + /// + /// Note: Result is NOT normalized -- the value is in [0, 2^255), so the 19 encodings of + /// 2^255-19 .. 2^255-1 decode to values >= p. Call `normalize` before comparing or encoding. + pub const fn decode_255(bytes: &[u8; 32]) -> Self { + let b = bytes; + let x0 = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); + let x1 = u64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]); + let x2 = u64::from_le_bytes([b[16], b[17], b[18], b[19], b[20], b[21], b[22], b[23]]); + let x3 = u64::from_le_bytes([b[24], b[25], b[26], b[27], b[28], b[29], b[30], b[31]]); + Self::decode64_255(&[x0, x1, x2, x3]) + } + + /// Repacks a 256-bit little-endian integer held as four 64-bit words into the five 51-bit limbs + /// of a [`CoordField`], where the represented value is + /// `z0 + z1*2^51 + z2*2^102 + z3*2^153 + z4*2^204`. Each limb is a 51-bit window of the input, + /// straddling word boundaries; the `& M51` masks make every limb non-negative and leave headroom + /// for lazily-carried arithmetic. Bit 255 of the input is dropped -- see [`Self::decode_255`]. + /// Inverse of [`Self::encode64_255`]. + /// Input: A 256-bit integer as four little-endian 64-bit words, least significant word first. + /// Output: Bits 0..254 of that integer as a [`CoordField`]. + /// + /// Note: Result is NOT normalized -- see the note on [`Self::decode_255`]. + const fn decode64_255(x: &[u64; 4]) -> Self { + let [x0, x1, x2, x3] = *x; + let z0 = x0 as i64 & M51; + let z1 = (x0 >> 51 | x1 << 13) as i64 & M51; + let z2 = (x1 >> 38 | x2 << 26) as i64 & M51; + let z3 = (x2 >> 25 | x3 << 39) as i64 & M51; + let z4 = (x3 >> 12) as i64 & M51; + Self([z0, z1, z2, z3, z4]) + } + + // TODO -- needs a docstring + pub fn inv(&self) -> Self { + // let (x2, t) = self.pow_p_sub5_div8(); + // t.sqr_n(3).mul(&x2) + + #[inline(always)] + fn is_zero(x: &[u64; 4]) -> Condition { + // todo -- we'll get this when we merge /pull/63 and then rebase this branch on top + Condition::::is_zero(x[0] | x[1] | x[2] | x[3]) + } + + let u = self.normalize().encode64_255(); + let mut v = [0; 4]; + let success = modular::mod_odd_inverse(&P64, &u, &mut v); + debug_assert!((success | (is_zero(&u) & is_zero(&v))).to_bool_var()); + Self::decode64_255(&v) + } + + // TODO -- needs a docstring + // TODO -- why are we returning the same ref we were passed? + pub fn inv_mut(&mut self) -> &mut Self { + *self = self.inv(); + self + } + + // TODO -- needs a docstring + pub fn inv_var(&self) -> Self { + // TODO Use a vartime optimized version of modular::mod_odd_inverse + self.inv() + } + + // TODO -- needs a docstring + // TODO -- why are we returning the same ref we were passed? + pub fn inv_mut_var(&mut self) -> &mut Self { + *self = self.inv_var(); + self + } + + // TODO -- needs a docstring + // TODO Requires normalized self + pub const fn is_one(&self) -> Condition { + let [x0, x1, x2, x3, x4] = self.0; + let x0 = x0 ^ 1; + Condition::::is_zero(x0 | x1 | x2 | x3 | x4) + } + + // TODO -- needs a docstring + // TODO Requires normalized self + pub const fn is_one_var(&self) -> bool { + self.is_one().to_bool_var() + } + + // TODO -- needs a docstring + // TODO Requires normalized self + pub const fn is_zero(&self) -> Condition { + let [x0, x1, x2, x3, x4] = self.0; + Condition::::is_zero(x0 | x1 | x2 | x3 | x4) + } + + // TODO -- needs a docstring + // TODO Requires normalized self + pub const fn is_zero_var(&self) -> bool { + self.is_zero().to_bool_var() + } + + // TODO -- needs a docstring + pub const fn mul(&self, rhs: &Self) -> Self { + #[inline(always)] + const fn m(x: i64, y: i64) -> i128 { + x as i128 * y as i128 + } + + let [x0, x1, x2, x3, x4] = self.0; + let [y0, y1, y2, y3, y4] = rhs.0; + let [t1, t2, t3, t4] = [y1 * 19, y2 * 19, y3 * 19, y4 * 19]; + + Self::reduce_products( + m(x0, y0) + m(x1, t4) + m(x2, t3) + m(x3, t2) + m(x4, t1), + m(x0, y1) + m(x1, y0) + m(x2, t4) + m(x3, t3) + m(x4, t2), + m(x0, y2) + m(x1, y1) + m(x2, y0) + m(x3, t4) + m(x4, t3), + m(x0, y3) + m(x1, y2) + m(x2, y1) + m(x3, y0) + m(x4, t4), + m(x0, y4) + m(x1, y3) + m(x2, y2) + m(x3, y1) + m(x4, y0), + ) + } + + // TODO -- needs a docstring + pub const fn mul_u32(&self, rhs: u32) -> Self { + #[inline(always)] + const fn m(x: i64, y: i64) -> i128 { + x as i128 * y as i128 + } + + let [x0, x1, x2, x3, x4] = self.0; + let y0 = rhs as i64; + + Self::reduce_products(m(x0, y0), m(x1, y0), m(x2, y0), m(x3, y0), m(x4, y0)) + } + + /// Negates the components of self + pub const fn negate(&self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + Self([-x0, -x1, -x2, -x3, -x4]) + } + + pub fn negate_mut(&mut self) -> &mut Self { + *self = self.negate(); + self + } + + // TODO Module consumers should instead have a method to construct from [u64; 4] (or indeed a Nat<4>) + pub const fn new(x0: i64, x1: i64, x2: i64, x3: i64, x4: i64) -> Self { + Self([x0, x1, x2, x3, x4]) + } + + pub const fn normalize(&self) -> Self { + let y = (self.0[4] >> (51 - 1)) & 1; + self.reduce(y).reduce(-y) + } + + pub fn normalize_mut(&mut self) -> &mut Self { + *self = self.normalize(); + self + } + + pub const fn one() -> Self { + Self([1, 0, 0, 0, 0]) + } + + // TODO Better name -- is_odd() ? + pub const fn parity(&self) -> u8 { + (self.0[0] & 1) as u8 + } + + // TODO Addition chain macro/function + const fn pow_p_sub5_div8(&self) -> (Self, Self) { + // TODO -- where is this from? + // z = x^((p-5)/8) = x^FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD + // (250 1s) (1 0s) (1 1s) + // Addition chain: [1] 2 3 5 10 15 25 50 75 125 [250] + + let x = self; + let x2 = x.sqr().mul(x); + let x3 = x2.sqr().mul(x); + let x5 = x3.sqr_n(2).mul(&x2); + let x10 = x5.sqr_n(5).mul(&x5); + let x15 = x10.sqr_n(5).mul(&x5); + let x25 = x15.sqr_n(10).mul(&x10); + let x50 = x25.sqr_n(25).mul(&x25); + let x75 = x50.sqr_n(25).mul(&x25); + let x125 = x75.sqr_n(50).mul(&x50); + let x250 = x125.sqr_n(125).mul(&x125); + + let z = x250.sqr_n(2).mul(x); + (x2, z) + } + + // TODO -- what is this and where is it from? + const fn reduce(&self, y: i64) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + + let t4 = x4 & M51; + let t5 = (x4 >> 51) + y; + + let mut c = t5 * 19; + + c += x0; + let z0 = c & M51; + c >>= 51; + c += x1; + let z1 = c & M51; + c >>= 51; + c += x2; + let z2 = c & M51; + c >>= 51; + c += x3; + let z3 = c & M51; + c >>= 51; + c += t4; + let z4 = c; + + Self([z0, z1, z2, z3, z4]) + } + + // TODO -- what is this and where is it from? + #[inline] + const fn reduce_products(p0: i128, p1: i128, p2: i128, p3: i128, p4: i128) -> Self { + #[inline(always)] + const fn split(x: i128) -> (i64, i64) { + (x as i64 & M51, (x >> 51) as i64) + } + + let (t0, c1) = split(p0); + let (t1, c2) = split(p1); + let (t2, c3) = split(p2); + let (t3, c4) = split(p3); + let (t4, c5) = split(p4 + c4 as i128); + + let u0 = t0 + c5 * 19; + let u1 = t1 + c1; + let u2 = t2 + c2; + let u3 = t3 + c3; + + Self([ + u0 & M51, + (u1 & M51) + (u0 >> 51), + (u2 & M51) + (u1 >> 51), + (u3 & M51) + (u2 >> 51), + t4 + (u3 >> 51), + ]) + } + + // TODO -- what is this and where is it from? + // TODO -- why is it pub and now pub(crate) + pub const fn sqr(&self) -> Self { + #[inline(always)] + const fn m(x: i64, y: i64) -> i128 { + x as i128 * y as i128 + } + + let [x0, x1, x2, x3, x4] = self.0; + let [y0, y1, y2, y3] = [x0 * 2, x1 * 2, x2 * 2, x3 * 2]; + let [t3, t4] = [x3 * 19, x4 * 19]; + + Self::reduce_products( + m(x0, x0) + m(y1, t4) + m(y2, t3), + m(y0, x1) + m(y2, t4) + m(x3, t3), + m(y0, x2) + m(x1, x1) + m(y3, t4), + m(y0, x3) + m(y1, x2) + m(x4, t4), + m(y0, x4) + m(y1, x3) + m(x2, x2), + ) + } + + // TODO -- what is this and where is it from? + // TODO -- why is it pub and now pub(crate) + pub fn sqr_mut(&mut self) -> &mut Self { + *self = self.sqr(); + self + } + + // TODO -- what is this and where is it from? + // TODO -- why is it pub and now pub(crate) + pub const fn sqr_n(&self, mut n: usize) -> Self { + let mut z = *self; + while n > 0 { + z = z.sqr(); + n -= 1; + } + z + } + + // TODO -- what is this and where is it from? + // TODO -- why is it pub and now pub(crate) + pub const fn sqrt_ratio_var(u: &Self, v: &Self) -> Option { + let uv = u.mul(v); + let v2 = v.sqr(); + let uv3 = v2.mul(&uv); + let uv7 = v2.sqr().mul(&uv3); + + let (_, w) = uv7.pow_p_sub5_div8(); + let x = w.mul(&uv3); + let vx2 = x.sqr().mul(v); + + let t = vx2.sub(u).normalize(); + + // TODO -- is this doing a conditional on a secret value? + if t.is_zero_var() { + return Some(x); + } + + let t = vx2.add(u).normalize(); + if t.is_zero_var() { + const ROOT_NEG_ONE: CoordField = CoordField([ + -0x0001E4D8B5F15F50, 0x0000D5A5FC8F189E, -0x000010A16342F3A0, -0x00007A6A597FB361, + 0x0002B8324804FC1E, + ]); + + return Some(x.mul(&ROOT_NEG_ONE)); + } + + None + } + + /// Subtract rhs from self, component-wise. + // TODO -- why is this pub and not pub(crate) ? + // TODO -- how does this handle wrapping / overflow? + pub const fn sub(&self, rhs: &Self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + let [y0, y1, y2, y3, y4] = rhs.0; + Self([x0 - y0, x1 - y1, x2 - y2, x3 - y3, x4 - y4]) + } + + /// Subtract CoordField::One from self, component-wise, and return a new CoordField. + // TODO -- why is this pub and not pub(crate) ? + // TODO -- how does this handle wrapping / overflow? + pub const fn sub_one(&self) -> Self { + let [x0, x1, x2, x3, x4] = self.0; + Self([x0 - 1, x1, x2, x3, x4]) + } + + /// Subtract CoordField::One from self, component-wise, in-place. + // TODO -- do we really need both versions of this? + // TODO -- why is this pub and not pub(crate) ? + // TODO -- how does this handle wrapping / overflow? + pub fn sub_one_mut(&mut self) -> &mut Self { + self.0[0] -= 1; + self + } + + pub const fn zero() -> Self { + Self([0; 5]) + } +} + +// TODO -- at least a basic unit test for each math function + +// TODO -- from RFC7748 s. 5: +// u-coordinate array: +// When receiving such an array, implementations of X25519 +// (but not X448) MUST mask the most significant bit in the final byte. +// This is done to preserve compatibility with point formats that +// reserve the sign bit for use in other protocols and to increase +// resistance to implementation fingerprinting. +// + +// TODO -- from RFC7748 s. 5: +// Implementations MUST accept non-canonical values and process them as +// if they had been reduced modulo the field prime. The non-canonical +// values are 2^255 - 19 through 2^255 - 1 for X25519 and 2^448 - 2^224 +// - 1 through 2^448 - 1 for X448. + +#[cfg(test)] +mod curve25519_tests { + use super::*; + + /// Alice's public key from the X25519 test vector in Section 6.1 of RFC 7748, used here purely + /// as a fixed value for the byte <-> limb codec. It is less than p (its top byte is 0x6A), so + /// its encoding is canonical and it needs no normalization to round-trip exactly. + const KNOWN_VALUE: [u8; 32] = [ + 0x85, 0x20, 0xF0, 0x09, 0x89, 0x30, 0xA7, 0x54, // + 0x74, 0x8B, 0x7D, 0xDC, 0xB4, 0x3E, 0xF7, 0x5A, // + 0x0D, 0xBF, 0x3A, 0x0D, 0x26, 0x38, 0x1A, 0xF4, // + 0xEB, 0xA4, 0xA9, 0x8E, 0xAA, 0x9B, 0x4E, 0x6A, + ]; + + /// The same value as five 51-bit limbs: `KNOWN_LIMBS[i] == (v >> (51 * i)) & (2^51 - 1)`, where + /// v is `KNOWN_VALUE` read as a little-endian integer. Computed independently of this crate. + const KNOWN_LIMBS: [i64; 5] = + [0x7308909F02085, 0x69B8FB16E8A94, 0x4EAFC356BDCFA, 0x275FA0D1C1306, 0x6A4E9BAA8EA9A]; + + #[test] + fn decode_255_known_value() { + assert_eq!(CoordField::decode_255(&KNOWN_VALUE).0, KNOWN_LIMBS); + + // Bit 255 is not part of the field element, so setting it must not change the result. + let mut bit_255_set = KNOWN_VALUE; + bit_255_set[31] |= 0x80; + assert_eq!(CoordField::decode_255(&bit_255_set).0, KNOWN_LIMBS); + } + + #[test] + fn encode_255_known_value() { + // Start from a non-zero buffer so a partial write cannot pass by accident. + let mut bytes = [0xFF_u8; 32]; + CoordField(KNOWN_LIMBS).encode_255(&mut bytes); + assert_eq!(bytes, KNOWN_VALUE); + } +} diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs new file mode 100644 index 000000000..2073fce8d --- /dev/null +++ b/crypto/ed/src/lib.rs @@ -0,0 +1,22 @@ +//! The Edwards curve family includes curve25519 and curve448 which are used to construct +//! the EdDSA signature schemes Ed25519 and Ed448, and the XDH key exchange schemes X25519 and X448. +//! +//! # Standards +//! +//! Standardization of the Edwards Curve algorithms is spread across a number of documents: +//! * RFC7748 specifies the core elliptic curves "curve25519" and "curve448", as well as the XDH key exchange algorithms X25519 and X448 built on top of them. +//! * RFC8032 specifies the EdDSA digital signature algorithms, also built on top of curve25519 and curve448. +//! * FIPS 186-5 section 7 specifies the EdDSA digital signature algorithms, essentially re-published from RFC 8032. +//! * NIST SP 800-186 gives an excellent treatment of the mathematics of all the families of elliptic curves used in cryptography, including Edwards curves. +//! +//! TODO -- is XDH specified in a NIST doc? +//! + +#![no_std] +#![forbid(unsafe_code)] + +// TODO -- #![forbid(missing_docs)] +// Add back at the end + +mod curve25519; +pub mod x25519; diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs new file mode 100644 index 000000000..467c92cab --- /dev/null +++ b/crypto/ed/src/x25519.rs @@ -0,0 +1,178 @@ +use bouncycastle_utils::ct::Condition; +use rand::RngCore; + +use crate::curve25519::CoordField; + +// TODO -- I would expect that a bunch of this is shared with Ed25519 and should be moved to curve25519.rs +// but I'll wait until I get to the Ed25519 implementation and see what's shared. + +pub const POINT_SIZE: usize = 32; +pub const SCALAR_SIZE: usize = 32; + +// TODO -- what is this? +const C_A: u32 = 486662; +// TODO -- what is this? +const C_A24: u32 = (C_A + 2) / 4; + +// TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait + +fn are_all_zeros(r: &[u8]) -> bool { + let mut bits = 0; + for i in r.iter() { + bits |= i; + } + bits == 0 +} + +// TODO -- what is this and where is it from? +// TODO -- why is it pub and now pub(crate) +pub fn calculate_agreement( + k: &[u8; SCALAR_SIZE], + u: &[u8; POINT_SIZE], + r: &mut [u8; POINT_SIZE], +) -> bool { + scalar_mult(k, u, r); + !are_all_zeros(&r[..POINT_SIZE]) +} + +fn decode_u32(bs: &[u8]) -> u32 { + (bs[0] as u32) | (bs[1] as u32) << 8 | (bs[2] as u32) << 16 | (bs[3] as u32) << 24 +} + +fn decode_scalar(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { + for (i, n_i) in n.iter_mut().enumerate() { + let pos = i * 4; + *n_i = decode_u32(&k[pos..(pos + 4)]); + } + + n[0] &= 0xFFFFFFF8_u32; + n[7] &= 0x7FFFFFFF_u32; + n[7] |= 0x40000000_u32; +} + +/// `decodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. +/// The most significant bit of the final byte is unused by X25519 and MUST be masked off before +/// decoding, so that u-coordinates carrying a sign bit in that position (as Ed25519 encodings do) +/// stay compatible. Non-canonical values -- u >= p, i.e. the encodings of 2^255-19 .. 2^255-1 -- +/// MUST be accepted rather than rejected, which is why the result is deliberately left +/// un-normalized here; the ladder in [`scalar_mult`] reduces it as it goes. +/// Input: A 32-byte little-endian u-coordinate. +/// Output: The low 255 bits of that value as a [`CoordField`]. +// TODO -- added by Claude; need to check and use. +fn decode_u_coordinate(u: &[u8; POINT_SIZE]) -> CoordField { + CoordField::decode_255(u) +} + +/// `encodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. +/// The RFC's `u = u % p` step is [`CoordField::normalize`]: an X25519 output must be the canonical +/// encoding of its residue class, even though non-canonical values MUST be accepted on the way in +/// (see [`decode_u_coordinate`]). Bit 255 of the final byte is written as zero, since it is unused +/// by X25519. +/// Input: A u-coordinate, in any state of reduction; a 32-byte output buffer. +/// Output: The canonical 32-byte little-endian encoding of that value, written to `r`. +// TODO -- added by Claude; need to check and use. +fn encode_u_coordinate(u: &CoordField, r: &mut [u8; POINT_SIZE]) { + u.normalize().encode_255(r); +} + +// TODO make this match the style of MLDSA keygen +pub fn generate_private_key(random: &mut dyn RngCore, k: &mut [u8; SCALAR_SIZE]) { + random.fill_bytes(&mut k[..]); + + k[0] &= 0xF8; + k[SCALAR_SIZE - 1] &= 0x7F; + k[SCALAR_SIZE - 1] |= 0x40; +} + +// TODO make this match the style of MLDSA keygen +pub fn generate_public_key(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { + scalar_mult_base(k, r); +} + +// TODO: move to curve25519.rs ? +fn point_double(x: &mut CoordField, z: &mut CoordField) { + let (mut a, mut b) = x.apm(z); + a.sqr_mut(); + b.sqr_mut(); + *x = a.mul(&b); + + a = a.sub(&b); + let t = a.mul_u32(C_A24).add(&b); + *z = t.mul(&a); +} + +// TODO -- precompute what? +pub fn precompute() { + // TODO (or remove if precomputation inlined to static) + // curve25519::precompute(); +} + +// TODO -- "For X25519, the unused, most significant bit MUST be zero." + +fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], r: &mut [u8; POINT_SIZE]) { + let mut n = [0_u32; 8]; + decode_scalar(k, &mut n); + + let x1 = CoordField::decode_255(u); + let mut x2 = x1; + let mut z2 = CoordField::one(); + let mut x3 = CoordField::one(); + let mut z3 = CoordField::zero(); + + let mut t1; + let mut t2; + + debug_assert_eq!(1, n[7] >> 30); + + let mut bit: usize = 253; + let mut cond_swap = Condition::::from_bool::(); + loop { + (t1, x3) = x3.apm(&z3); + (z3, x2) = x2.apm(&z2); + t1 = t1.mul(&x2); + x3 = x3.mul(&z3); + z3.sqr_mut(); + x2.sqr_mut(); + + t2 = z3.sub(&x2); + z2 = t2.mul_u32(C_A24).add(&x2).mul(&t2); + x2 = x2.mul(&z3); + + (x3, z3) = t1.apm(&x3); + x3.sqr_mut(); + z3 = z3.sqr().mul(&x1); + + let cond_bit = Condition::::is_bit_set(n[bit >> 5] as i64, (bit & 0x1F) as i64); + cond_swap ^= cond_bit; + CoordField::cswap(cond_swap, &mut x2, &mut x3); + CoordField::cswap(cond_swap, &mut z2, &mut z3); + cond_swap = cond_bit; + + if bit < 3 { + break; + }; + bit -= 1; + } + + debug_assert!(!cond_swap.to_bool_var()); + + for _ in 0..3 { + point_double(&mut x2, &mut z2); + } + + x2 = x2.mul(&z2.inv()); + x2.normalize().encode_255(r); +} + +fn scalar_mult_base(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { + // Equivalent (but much slower) + let mut u = [0_u8; POINT_SIZE]; + u[0] = 9; + scalar_mult(k, &u, r); + + // TODO + // let (y, z) = curve25519::scalar_mult_base_yz(k); + // let (t, u) = z.apm(&y); + // let v = t.mul(&u.inv()); + // v.normalize().encode(r); +} diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs new file mode 100644 index 000000000..ffd96828c --- /dev/null +++ b/crypto/ed/tests/x25519_tests.rs @@ -0,0 +1,130 @@ +use bouncycastle_edcurves::x25519::*; +use bouncycastle_hex as hex; + +#[test] +fn agreement() { + precompute(); + + let mut ka = [0_u8; SCALAR_SIZE]; + let mut kb = [0_u8; SCALAR_SIZE]; + let mut qa = [0_u8; POINT_SIZE]; + let mut qb = [0_u8; POINT_SIZE]; + let mut sa = [0_u8; POINT_SIZE]; + let mut sb = [0_u8; POINT_SIZE]; + + let mut random = rand::rng(); + + for i in 1..=100 { + // Each party generates an ephemeral private key, ... + generate_private_key(&mut random, &mut ka); + generate_private_key(&mut random, &mut kb); + + // ... publishes their public key, ... + generate_public_key(&ka, &mut qa); + generate_public_key(&kb, &mut qb); + + // ... computes the shared secret, ... + let ra = calculate_agreement(&ka, &qb, &mut sa); + let rb = calculate_agreement(&kb, &qa, &mut sb); + + // ... which is the same for both parties. + assert_eq!(ra, rb, "ECDH #{}", i); + assert_eq!(sa, sb, "ECDH #{}", i); + } +} + +#[test] +fn iterated() { + check_iterated(1000); +} + +#[ignore] +#[test] +fn iterated_full() { + check_iterated(1000000); +} + +// TODO -- this appears to be the vector from RFC7749 s.5.2 +// If so, document that. +#[test] +fn vector_1() { + check_vector( + "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", + "e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c", + "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552", + "Vector #1", + ); +} + +#[test] +fn vector_2() { + check_vector( + "4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d", + "e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493", + "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957", + "Vector #2", + ); +} + +// TODO -- what on earth is this? We're going to key_agree this with itself 1 million times? +// Why? What is this testing that's not already covered by the regular KATs? +fn check_iterated(count: usize) { + assert_eq!(POINT_SIZE, SCALAR_SIZE); + + precompute(); + + let mut k = [0_u8; POINT_SIZE]; + k[0] = 9; + let mut u = [0_u8; POINT_SIZE]; + u[0] = 9; + let mut r = [0_u8; POINT_SIZE]; + + let mut iterations = 0; + while iterations < count { + calculate_agreement(&k, &u, &mut r); + + u = k; + k = r; + + iterations += 1; + + match iterations { + 1 => check_value( + &k, + "Iterated @1", + "422c8e7a6227d7bca1350b3e2bb7279f7897b87bb6854b783c60e80311ae3079", + ), + 1000 => check_value( + &k, + "Iterated @1000", + "684cf59ba83309552800ef566f2f4d3c1c3887c49360e3875f2eb94d99532c51", + ), + 1000000 => check_value( + &k, + "Iterated @1000000", + "7c3911e0ab2586fd864497297e575e6f3bc601c0883c30df5f4dd2d24f665424", + ), + _ => {} + } + } +} + +fn check_value(n: &[u8], text: &str, se: &str) { + let e = hex::decode(se).unwrap(); + assert_eq!(e.as_slice(), n, "{}", text); +} + +fn check_vector(sk: &str, su: &str, se: &str, text: &str) { + let k: [u8; SCALAR_SIZE] = hex::decode(sk).try_into().unwrap(); + let u: [u8; POINT_SIZE] = hex::decode(su).try_into().unwrap(); + + let mut r = [0_u8; POINT_SIZE]; + calculate_agreement(&k, &u, &mut r); + check_value(&r, text, se); +} + +// fn decode_hex(hex: &str) -> [u8; N] { +// let mut result = [0; N]; +// hex::decode_to_slice(hex, &mut result).unwrap(); +// result +// } From 71183d826378680669a7de3e5ee327bb89ea08c4 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Sun, 23 Aug 2026 19:56:00 -0500 Subject: [PATCH 02/26] updated cargo.toml --- Cargo.toml | 2 +- src/lib.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 76a8f63ed..c11a03f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ bouncycastle-ascon.workspace = true bouncycastle-base64.workspace = true bouncycastle-cipher.workspace = true bouncycastle-core.workspace = true -bouncycastle-edcurves.workspace = true +bouncycastle-ed.workspace = true bouncycastle-factory.workspace = true bouncycastle-hex.workspace = true bouncycastle-hkdf.workspace = true diff --git a/src/lib.rs b/src/lib.rs index ae250f332..2b2a0fedf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub use bouncycastle_ascon as ascon; pub use bouncycastle_base64 as base64; pub use bouncycastle_cipher as cipher; pub use bouncycastle_core as core; +pub use bouncycastle_ed as ed; pub use bouncycastle_factory as factory; pub use bouncycastle_hex as hex; pub use bouncycastle_hkdf as hkdf; From b455d0ca1c7a108df3b4e7eddd9b60a2656e54eb Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Tue, 25 Aug 2026 16:34:10 -0500 Subject: [PATCH 03/26] progress towards 25519 --- Cargo.toml | 1 + crypto/ed/Cargo.toml | 1 + crypto/ed/src/curve25519.rs | 120 +++++++++++++++++++++---------- crypto/ed/src/lib.rs | 4 ++ crypto/ed/src/rfc7748.rs | 140 ++++++++++++++++++++++++++++++++++++ crypto/ed/src/x25519.rs | 10 +++ crypto/math/src/modular.rs | 46 ++++++++++++ 7 files changed, 286 insertions(+), 36 deletions(-) create mode 100644 crypto/ed/src/rfc7748.rs create mode 100644 crypto/math/src/modular.rs diff --git a/Cargo.toml b/Cargo.toml index c11a03f02..ae702e142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ bouncycastle-factory = { path = "./crypto/factory" } bouncycastle-hex = { path = "./crypto/hex" } bouncycastle-hkdf = { path = "./crypto/hkdf" } bouncycastle-hmac = { path = "./crypto/hmac" } +bouncycastle-math = { path = "./crypto/math" } bouncycastle-mlkem = { path = "./crypto/mlkem" } bouncycastle-mlkem-lowmemory = { path = "./crypto/mlkem-lowmemory" } bouncycastle-mldsa = { path = "./crypto/mldsa" } diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index 4d879f0db..419c364a2 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -4,6 +4,7 @@ edition.workspace = true version.workspace = true [dependencies] +bouncycastle-math.workspace = true bouncycastle-utils.workspace = true [dev-dependencies] diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index f7e8bb7eb..177ccd01c 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -1,12 +1,60 @@ use bouncycastle_utils::ct::Condition; -use math::modular; +/// The basic data value for any elliptic curve implementation is a point on the curve. +/// Holds the u-coordinate, which is an element of GF(p) with p = 2^255 - 19 for curve25519, held +/// here in a `[u8; 32]` as the representation of a point on a Montgomery curve. +/// +/// +/// # Mathematical operations implemented: +/// None. +/// All mathematical operations on a u-coordinate are implemented on [`Scalar25519`]. +/// +/// # Math Background +/// The group law on elliptic curve points is point addition where A + B yields a new point C on the +/// curve. +/// The special case of adding a point to itself, A + A is called "point doubling" and is often +/// implemented separately as this can be done more efficiently than the general case. +/// Finally, repeated addition with itself, A + A + ... + A is represented as k * A for a scalar k and +/// called "scalar multiplication", again, implemented separately. +/// +/// Curve points are represented here in Montgomery form by their u-coordinate, which defines a point +/// on the curve or on the quadratic twist up to sign; ie it names the pair {P, -P}. +/// This means that point doubling and scalar multiplication are well-defined over u-coordinates since +/// they commute with negation; ie k*(-A) = -(k*A), but general point addition is not. +/// Fortunately, XDH defined in RFC7748 only requires scalar multiplication, not general point addition. +type UCoordinate25519 = [u8; 32]; + +/// Scalar values are integers used as the k value in scalar multiplication of points. +/// +/// # Mathematical operations implemented: +/// +/// * EC scalar multiplication: k * A: via `impl core::ops::Mul for Scalar25519`, with `Output = UCoordinate25519` +struct Scalar25519([u8; 32]); + +impl core::ops::Mul for Scalar25519 { + type Output = UCoordinate25519; + + /// EC scalar multiplication `k * A`, computed by the Montgomery ladder of Section 5 of + /// RFC7748; ie this is the X25519 function itself. + /// + /// Input: self, the scalar `k`; rhs, the u-coordinate of a point `A`. + /// Output: the u-coordinate of `k * A`. + // TODO -- unimplemented. `todo!()` rather than a literally empty body because `mul` has to + // produce a `[u8; 32]`. + fn mul(self, rhs: UCoordinate25519) -> Self::Output { + todo!() + } +} + +/// todo ??? const M51: i64 = (1 << 51) - 1; -pub const P32: [u32; 8] = [ +/// todo ??? +const P32: [u32; 8] = [ 0xFFFFFFED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, ]; -pub const P64: [u64; 4] = +/// todo ??? +const P64: [u64; 4] = [0xFFFFFFFFFFFFFFED, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7FFFFFFFFFFFFFFF]; // TODO Add magnitude, normalized fields to CoordField in debug mode and track validity through all operations @@ -60,7 +108,7 @@ impl CoordField { // TODO Requires normalized inputs // TODO -- what uses this? pub const fn are_equal_var(lhs: &Self, rhs: &Self) -> bool { - Self::are_equal(lhs, rhs).to_bool_var() + Self::are_equal(lhs, rhs).to_bool() } // TODO -- needs a docstring @@ -244,36 +292,36 @@ impl CoordField { Self([z0, z1, z2, z3, z4]) } - // TODO -- needs a docstring - pub fn inv(&self) -> Self { - // let (x2, t) = self.pow_p_sub5_div8(); - // t.sqr_n(3).mul(&x2) - - #[inline(always)] - fn is_zero(x: &[u64; 4]) -> Condition { - // todo -- we'll get this when we merge /pull/63 and then rebase this branch on top - Condition::::is_zero(x[0] | x[1] | x[2] | x[3]) - } - - let u = self.normalize().encode64_255(); - let mut v = [0; 4]; - let success = modular::mod_odd_inverse(&P64, &u, &mut v); - debug_assert!((success | (is_zero(&u) & is_zero(&v))).to_bool_var()); - Self::decode64_255(&v) - } - - // TODO -- needs a docstring - // TODO -- why are we returning the same ref we were passed? - pub fn inv_mut(&mut self) -> &mut Self { - *self = self.inv(); - self - } - - // TODO -- needs a docstring - pub fn inv_var(&self) -> Self { - // TODO Use a vartime optimized version of modular::mod_odd_inverse - self.inv() - } + // // TODO -- needs a docstring + // pub fn inv(&self) -> Self { + // // let (x2, t) = self.pow_p_sub5_div8(); + // // t.sqr_n(3).mul(&x2) + // + // #[inline(always)] + // fn is_zero(x: &[u64; 4]) -> Condition { + // // todo -- we'll get this when we merge /pull/63 and then rebase this branch on top + // Condition::::is_zero(x[0] | x[1] | x[2] | x[3]) + // } + // + // let u = self.normalize().encode64_255(); + // let mut v = [0; 4]; + // let success = modular::mod_odd_inverse(&P64, &u, &mut v); + // debug_assert!((success | (is_zero(&u) & is_zero(&v))).to_bool_var()); + // Self::decode64_255(&v) + // } + + // // TODO -- needs a docstring + // // TODO -- why are we returning the same ref we were passed? + // pub fn inv_mut(&mut self) -> &mut Self { + // *self = self.inv(); + // self + // } + + // // TODO -- needs a docstring + // pub fn inv_var(&self) -> Self { + // // TODO Use a vartime optimized version of modular::mod_odd_inverse + // self.inv() + // } // TODO -- needs a docstring // TODO -- why are we returning the same ref we were passed? @@ -293,7 +341,7 @@ impl CoordField { // TODO -- needs a docstring // TODO Requires normalized self pub const fn is_one_var(&self) -> bool { - self.is_one().to_bool_var() + self.is_one().to_bool() } // TODO -- needs a docstring @@ -306,7 +354,7 @@ impl CoordField { // TODO -- needs a docstring // TODO Requires normalized self pub const fn is_zero_var(&self) -> bool { - self.is_zero().to_bool_var() + self.is_zero().to_bool() } // TODO -- needs a docstring diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index 2073fce8d..30a1fb435 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -18,5 +18,9 @@ // TODO -- #![forbid(missing_docs)] // Add back at the end +extern crate alloc; +extern crate alloc; + mod curve25519; +mod rfc7748; pub mod x25519; diff --git a/crypto/ed/src/rfc7748.rs b/crypto/ed/src/rfc7748.rs new file mode 100644 index 000000000..83f185424 --- /dev/null +++ b/crypto/ed/src/rfc7748.rs @@ -0,0 +1,140 @@ +//! This is a from-scratch implementation of RFC7748, not starting from the bc-rust-nursery. +//! TODO -- Unclear if this will end up being better or worse than the bc-rust-nursery version. + +/// "The constant a24 is +/// (486662 - 2) / 4 = 121665 for curve25519/X25519" +// Name matches RFC +#[allow(non_upper_case_globals)] +const a24: i32 = 121665; + +#[allow(non_upper_case_globals)] +const p: 2^255 - 19 + +/// From RFC7748 as: +/// def decodeUCoordinate(u, bits): +/// u_list = [ord(b) for b in u] +/// # Ignore any unused bits. +/// if bits % 8: +/// u_list[-1] &= (1<<(bits%8))-1 +/// return decodeLittleEndian(u_list, bits) +// todo -- possibly `bits` should be a ? +fn decode_u_coordinate(u: &[u8], bits: usize) -> UCoordinate25519 { + let mut u_list = [0u8; 32]; + for (i, &byte) in u.iter().enumerate() { + u_list[i] = byte; + } + // Ignore any unused bits. + if bits % 8 != 0 { + u_list[31] &= (1 << (bits % 8)) - 1; + } + + u_list +} + +/// From RFC7748 as: +/// def encodeUCoordinate(u, bits): +/// u = u % p +/// return ''.join([chr((u >> 8*i) & 0xff) +/// for i in range((bits+7)/8)]) +fn encode_u_coordinate(u: UCoordinate25519, bits: usize) -> [u8; 32] { + let mut encoded = [0u8; 32]; + for i in 0..(bits + 7) / 8 { + encoded[i] = (u[i] >> (8 * i)) & 0xff; + } + + encoded +} + +/// From RFC7748 as: +/// def decodeScalar25519(k): +/// k_list = [ord(b) for b in k] +/// k_list[0] &= 248 +/// k_list[31] &= 127 +/// k_list[31] |= 64 +/// return decodeLittleEndian(k_list, 255) +/// +/// "For X25519, in +/// order to decode 32 random bytes as an integer scalar, set the three +/// least significant bits of the first byte and the most significant bit +/// of the last to zero, set the second most significant bit of the last +/// byte to 1 and, finally, decode as little-endian. This means that the +/// resulting integer is of the form 2^254 plus eight times a value +/// between 0 and 2^251 - 1 (inclusive)." +fn decode_scalar_25519(k: Scalar25519) -> [u8; 32] { + let mut k_list = k.clone(); + k_list[0] &= 248; + k_list[31] &= 127; + k_list[31] |= 64; + + k_list +} + +// "Here, the "bits" parameter should be set to 255 for X25519 and 448 for X448:" + +fn x25519(k: Scalar25519, u: UCoordinate25519) -> UCoordinate25519 { + xdh::<255, Scalar25519, UCoordinate25519>(k, u) +} + +// TODO -- need to define arithmetic ops for Scalar25519 + +#[allow(non_snake_case)] +fn xdh(k: Scalar, u: UCoordinate) -> UCoordinate { + let x_1 = u.clone(); + let mut x_2 = 1; + let mut z_2 = 0; + let mut x_3 = u.clone(); + let mut z_3 = 1; + let mut swap = 0; + + for t in (0..bits - 1).rev() { + let k_t = (k >> t) & 1; + swap ^= k_t; + + // Conditional swap + (x_2, x_3) = cswap(swap, x_2, x_3); + (z_2, z_3) = cswap(swap, z_2, z_3); + swap = k_t; + + let A = x_2 + z_2; + let AA = A ^ 2; + let B = x_2 - z_2; + let BB = B ^ 2; + let E = AA - BB; + let C = x_3 + z_3; + let D = x_3 - z_3; + let DA = D * A; + let CB = C * B; + x_3 = (DA + CB) ^ 2; + z_3 = x_1 * (DA - CB) ^ 2; + x_2 = AA * BB; + z_2 = E * (AA + a24 * E); + } + + // Conditional swap; see text below. + (x_2, x_3) = cswap(swap, x_2, x_3); + (z_2, z_3) = cswap(swap, z_2, z_3); + + x_2 * (z_2 ^ (p - 2)) +} + +fn cswap(swap: i32, x_2: i32, x_3: i32) -> (i32, i32) { + // "Where mask(swap) is the all-1 or all-0 word of the same length as x_2 + // and x_3, computed, e.g., as mask(swap) = 0 - swap." + fn mask(swap: i32) -> i32 { + 0 - swap + } + + let dummy = mask(swap) & (x_2 ^ x_3); + (x_2 ^ dummy, x_3 ^ dummy) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cswap() { + assert_eq!(cswap(0, 1, 2), (1, 2)); + assert_eq!(cswap(1, 1, 2), (2, 1)); + } +} diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 467c92cab..0f629ab22 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -16,6 +16,16 @@ const C_A24: u32 = (C_A + 2) / 4; // TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait +// TODO -- RFC7748: "When receiving such an array, implementations of X25519 +// (but not X448) MUST mask the most significant bit in the final byte." +// I think "mask" here means to assign 0 or 1 pseudo-randomly? + +// TODO -- RFC7748: " Implementations MUST accept non-canonical values and process them as +// if they had been reduced modulo the field prime. The non-canonical +// values are 2^255 - 19 through 2^255 - 1 for X25519 and 2^448 - 2^224 +// - 1 through 2^448 - 1 for X448. +// The TODO here is to unit test this. + fn are_all_zeros(r: &[u8]) -> bool { let mut bits = 0; for i in r.iter() { diff --git a/crypto/math/src/modular.rs b/crypto/math/src/modular.rs new file mode 100644 index 000000000..ca42536c4 --- /dev/null +++ b/crypto/math/src/modular.rs @@ -0,0 +1,46 @@ +//! This module provides generic functionality for modular arithmetic. +// TODO -- is this right to be its own crate? Presumably over time, we'll get more generic math functions and it'll make sense to have a math crate. + +// pub fn mod_odd_inverse(m: &[u64], x: &[u64], z: &mut [u64]) -> Condition { +// let len64 = m.len(); +// debug_assert!(len64 > 0); +// debug_assert!((m[0] & 1) != 0); +// debug_assert!(m[len64 - 1] != 0); +// +// let bits = (len64 << 6) - m[len64 - 1].leading_zeros() as usize; +// let len62 = bits.div_ceil(62); +// +// // TODO Stack allocate for small sizes +// let buf = &mut vec![0_i64; len62 * 5].into_boxed_slice(); +// +// let (m62, buf) = buf.split_at_mut(len62); +// let (d62, buf) = buf.split_at_mut(len62); +// let (e62, buf) = buf.split_at_mut(len62); +// let (f62, g62) = buf.split_at_mut(len62); +// +// encode62(bits, m, m62); +// e62[0] = 1; +// f62.copy_from_slice(m62); +// encode62(bits, x, g62); +// +// // We use the "half delta" variant here, with theta == delta - 1/2 +// let mut theta = 0_i64; +// let m0_inv64 = inverse64(m62[0] as u64) as i64; +// let max_divsteps = get_maximum_hddivsteps(bits); +// let mut t; +// +// for _ in (0..max_divsteps).step_by(62) { +// (theta, t) = hddivsteps::<62>(theta, f62[0], g62[0]); +// update_de62(len62, d62, e62, &t, m0_inv64, m62); +// update_fg62(len62, f62, g62, &t); +// } +// +// let sign_f62 = Condition::::is_negative(f62[len62 - 1]); +// cnegate62(len62, sign_f62, f62); +// cnormalize62(len62, sign_f62, d62, m62); +// +// decode62(bits, d62, z); +// debug_assert!(nat::lt(m.len(), z, m).to_bool_var()); +// +// equal_to(len62, f62, 1) & equal_to(len62, g62, 0) +// } From ba80ce54e09c6ae3eb7dc00eba543860af32044c Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Wed, 26 Aug 2026 10:19:38 -0500 Subject: [PATCH 04/26] progress on 25519 --- crypto/ed/src/curve25519.rs | 485 ++++++++++++++++++++++++++++++++---- crypto/ed/src/lib.rs | 2 - crypto/ed/src/rfc7748.rs | 140 ----------- crypto/ed/src/x25519.rs | 198 +++++++++++---- crypto/math/Cargo.toml | 6 + crypto/math/src/lib.rs | 16 ++ 6 files changed, 601 insertions(+), 246 deletions(-) delete mode 100644 crypto/ed/src/rfc7748.rs create mode 100644 crypto/math/Cargo.toml create mode 100644 crypto/math/src/lib.rs diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index 177ccd01c..8524e4c76 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -39,21 +39,44 @@ impl core::ops::Mul for Scalar25519 { /// /// Input: self, the scalar `k`; rhs, the u-coordinate of a point `A`. /// Output: the u-coordinate of `k * A`. + /// + /// Scope: X25519. // TODO -- unimplemented. `todo!()` rather than a literally empty body because `mul` has to // produce a `[u8; 32]`. + // TODO: Claude-generated, double-check fn mul(self, rhs: UCoordinate25519) -> Self::Output { todo!() } } -/// todo ??? +/// Mask for one limb: `2^51 - 1`. A [`CoordField`] holds the value +/// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204`, so `& M51` extracts a limb's own 51 bits and +/// leaves the overflow to be carried into the next limb (or, out of `x4`, folded back into `x0` +/// scaled by 19, since `2^255 == 19 mod p`). +/// +/// Scope: both. +// TODO: Claude-generated, double-check const M51: i64 = (1 << 51) - 1; -/// todo ??? +/// The field prime `p = 2^255 - 19` as eight little-endian 32-bit words, least significant first. +/// +/// Currently referenced nowhere in the crate; [`CoordField::normalize`] reduces via the `* 19` +/// fold rather than by comparing against `p`. +/// +/// Scope: neither -- dead as of today. +// TODO: Claude-generated, double-check const P32: [u32; 8] = [ 0xFFFFFFED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, ]; -/// todo ??? +/// The field prime `p = 2^255 - 19` as four little-endian 64-bit words, least significant first. +/// Note the low word is `...FFED` = `-19 mod 2^64`, and the top word has bit 63 clear, giving +/// `2^255 - 19` rather than `2^256 - 19`. +/// +/// Referenced only by the commented-out `modular::mod_odd_inverse` form of `inv`, so it is dead +/// until inversion is restored. +/// +/// Scope: both -- once inversion lands. +// TODO: Claude-generated, double-check const P64: [u64; 4] = [0xFFFFFFFFFFFFFFED, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7FFFFFFFFFFFFFFF]; @@ -69,14 +92,42 @@ pub struct CoordField([i64; 5]); // TODO -- all of these need at least simple unit tests impl CoordField { /// Add another [`CoordField`] to this one, component-wise. + /// + /// Lazily reduced: no carry propagation, so limb magnitudes grow by up to one bit and the result + /// is congruent mod p but not normalized. That is deliberate -- every consumer here funnels into + /// a [`Self::mul`], [`Self::sqr`] or [`Self::mul_u32`], each of which reduces through + /// [`Self::reduce_products`], so magnitudes never run away and an explicit carry pass is not + /// needed. + /// + /// Scope: both. In X25519 it is the `AA + a24 * E` of the Section 5 ladder (`x25519.rs:110`, + /// `148`). + // TODO: Claude-generated, double-check pub const fn add(&self, rhs: &Self) -> Self { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; Self([x0 + y0, x1 + y1, x2 + y2, x3 + y3, x4 + y4]) } - // TODO -- needs a docstring - pub const fn apm(&self, rhs: &Self) -> (Self, Self) { + /// Returns `(self + rhs, self - rhs)` in one pass over the limbs. + /// + /// Each half is the plain limb-wise [`Self::add`] / [`Self::sub`], with the same lazy-reduction + /// caveat: no carry propagation, so limbs may go negative and both results are congruent mod p + /// but not normalized. Fusing them avoids reading the limbs twice, and matches the fact that + /// the ladder always wants the sum and the difference of the same pair. + /// + /// This is Section 5 of RFC7748's `A = x_2 + z_2` / `B = x_2 - z_2` pair, and equally its + /// `C = x_3 + z_3` / `D = x_3 - z_3`. It is the workhorse of the ladder in [`crate::x25519`]. + /// + /// Note on the name: RFC7748 does not name this operation -- it writes the two assignments out + /// separately, so there is no spec name to match. This was previously called `apm`, an + /// abbreviation that appears nowhere in RFC7748 or RFC8032. It most likely came from the + /// BouncyCastle Java implementation (`X25519Field.apm`, whose `zp` / `zm` out-parameters + /// suggest "add plus minus"), but that expansion is inferred from behaviour and has not been + /// confirmed against the Java source. + /// + /// Scope: both x25519 and ed25519 + // TODO: Claude-generated, double-check + pub const fn sum_and_difference(&self, rhs: &Self) -> (Self, Self) { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; ( @@ -85,33 +136,71 @@ impl CoordField { ) } - // TODO -- why are we cloning? + /// Add one to self, i.e. add 1 to limb `x0`, returning a new [`CoordField`]. + /// + /// One is representable exactly in the low limb, so no carry or fold is involved; as with + /// [`Self::add`] the result is congruent mod p but not normalized. + /// + /// Scope: Ed25519 -- unused today. The birational map of Section 4.1 of RFC7748, + /// `y = (u - 1)/(u + 1)`, is what wants `add_one` and [`Self::sub_one`]; the X25519 ladder never + /// adds a constant. + // TODO -- why are we cloning? [there is no clone: `self.0` is `[i64; 5]`, which is `Copy`, so the + // destructuring bind is a register move, not an allocation.] + // TODO: Claude-generated, double-check pub const fn add_one(&self) -> Self { let [x0, x1, x2, x3, x4] = self.0; Self([x0 + 1, x1, x2, x3, x4]) } + /// [`Self::add_one`] in place. + /// + /// Scope: Ed25519 -- unused today; see [`Self::add_one`]. // TODO -- why are we returning the same ref we were passed? + // TODO: Claude-generated, double-check pub fn add_one_mut(&mut self) -> &mut Self { self.0[0] += 1; self } - // TODO Requires normalized inputs - // TODO -- what uses this? + /// Constant-time equality: `is_zero` over the OR of the limb-wise XORs. + /// + /// Requires both inputs normalized -- this compares representations, not residue classes, and a + /// value and that value plus p have different limbs. See the precondition discussion above + /// [`Self::encode_255`]. + /// + /// Scope: Ed25519 -- unused today. X25519 never compares field elements; the ladder's only + /// output check is the all-zeros test on the encoded bytes (`x25519.rs:29`). + // TODO: Claude-generated, double-check pub const fn are_equal(lhs: &Self, rhs: &Self) -> Condition { let [x0, x1, x2, x3, x4] = lhs.0; let [y0, y1, y2, y3, y4] = rhs.0; Condition::::is_zero((x0 ^ y0) | (x1 ^ y1) | (x2 ^ y2) | (x3 ^ y3) | (x4 ^ y4)) } - // TODO Requires normalized inputs - // TODO -- what uses this? + /// Variable-time [`Self::are_equal`], resolving to a `bool` and therefore branchable by the + /// caller. Only for values that are not secret. + /// + /// Requires both inputs normalized; see [`Self::are_equal`]. + /// + /// Scope: Ed25519 -- unused today; signature verification compares public values. + // TODO: Claude-generated, double-check pub const fn are_equal_var(lhs: &Self, rhs: &Self) -> bool { Self::are_equal(lhs, rhs).to_bool() } - // TODO -- needs a docstring + /// One weak-reduction pass: move each limb's overflow into the next, and fold the overflow out of + /// `x4` into `x0` scaled by 19 (`2^255 == 19 mod p`). + /// + /// Shifts are arithmetic, so a negative limb borrows from its neighbour correctly, and `& M51` + /// leaves every limb non-negative. The result is congruent mod p and has limbs near 51 bits, but + /// is not normalized -- `z0` in particular can exceed `2^51` after the `+ c5 * 19`. + /// + /// Verified congruent to its input mod p over 20,000 random values. + /// + /// Scope: both -- but currently unused by either, and structurally so: every ladder step passes + /// through a `mul`, `sqr` or `mul_u32`, all of which reduce via [`Self::reduce_products`], so + /// limb magnitudes never grow far enough to need a standalone carry. + // TODO: Claude-generated, double-check pub const fn carry(&self) -> Self { let [x0, x1, x2, x3, x4] = self.0; let c1 = x0 >> 51; @@ -127,13 +216,24 @@ impl CoordField { Self([z0, z1, z2, z3, z4]) } + /// [`Self::carry`] in place. + /// + /// Scope: both -- unused today; see [`Self::carry`]. // TODO -- why are we returning the same ref we were passed? + // TODO: Claude-generated, double-check pub fn carry_mut(&mut self) -> &mut Self { *self = self.carry(); self } /// Move the coordinates of x into self if `cond` is true. + /// + /// Constant time: every limb is rewritten unconditionally with a masked blend, so the branch is + /// not observable. See `bouncycastle_utils::ct::Condition`. + /// + /// Scope: Ed25519 -- unused today. Wanted for the table lookup in a fixed-base comb; X25519's + /// only conditional is the ladder swap, [`Self::cswap`]. + // TODO: Claude-generated, double-check pub fn cmov(&mut self, cond: Condition, x: &Self) -> &mut Self { // *self = Self::cselect(cond, x, self); cond.mov(x.0[0], &mut self.0[0]); @@ -145,6 +245,12 @@ impl CoordField { } /// Negate the components of self if `cond` is true. + /// + /// Constant time, as [`Self::cmov`]. + /// + /// Scope: Ed25519 -- unused today. Wanted for signed-digit fixed-base multiplication, where a + /// table entry is conditionally negated instead of storing both signs. + // TODO: Claude-generated, double-check pub fn cnegate(&mut self, cond: Condition) -> &mut Self { self.0[0] = cond.negate(self.0[0]); self.0[1] = cond.negate(self.0[1]); @@ -154,7 +260,12 @@ impl CoordField { self } - /// Returns a clone of lhs if `cond` is true, else returns rhs. + /// Returns a copy of lhs if `cond` is true, else a copy of rhs. + /// + /// Constant time, as [`Self::cmov`]; the non-mutating form of it. + /// + /// Scope: Ed25519 -- unused today; see [`Self::cmov`]. + // TODO: Claude-generated, double-check pub fn cselect(cond: Condition, lhs: &Self, rhs: &Self) -> Self { let [x0, x1, x2, x3, x4] = lhs.0; let [y0, y1, y2, y3, y4] = rhs.0; @@ -166,7 +277,15 @@ impl CoordField { Self([z0, z1, z2, z3, z4]) } - /// Swap the values of lhs and rhs if `cond` is true. + /// Conditionally Swap the values of lhs and rhs if `cond` is true. + /// + /// Constant time: both limb arrays are rewritten unconditionally with a masked exchange, so + /// nothing about `cond` -- and hence nothing about the scalar bit driving it -- leaks through + /// memory access patterns or timing. + /// + /// Scope: X25519. This is `cswap(swap, x_2, x_3)` from the Section 5 ladder of RFC7748, the + /// mechanism that makes the ladder scalar-bit oblivious. + // TODO: Claude-generated, double-check pub fn cswap(cond: Condition, lhs: &mut Self, rhs: &mut Self) { (lhs.0[0], rhs.0[0]) = cond.swap(lhs.0[0], rhs.0[0]); (lhs.0[1], rhs.0[1]) = cond.swap(lhs.0[1], rhs.0[1]); @@ -179,7 +298,7 @@ impl CoordField { // This comment was generated by Claude and left here to be considered unce I have // a better understanding of the code. // - // `decode_255` deliberately returns an un-normalized value: RFC 7748 Section 5 requires that + // `decode_u_coordinate` deliberately returns an un-normalized value: RFC 7748 Section 5 requires that // non-canonical u-coordinates (u >= p) be accepted rather than rejected. But `encode_255`, // `encode64_255`, `are_equal`, `are_equal_var` and `parity` all *require* a normalized input, // enforced today only by a documented precondition that every call site has to remember. All of @@ -206,7 +325,7 @@ impl CoordField { // magnitude bound that `reduce`'s carry chain assumes (`t5 * 19 + x0` must not overflow i64). // That residual bound is exactly what option 3 would track. - /// Encodes a field element as a 255-bit value in 32 little-endian bytes; the inverse of [`Self::decode_255`]. + /// Encodes a field element as a 255-bit value in 32 little-endian bytes; the inverse of [`Self::decode_u_coordinate`]. /// Bit 255 (the most significant bit of the last byte) is always written as zero. /// /// Callers that need that bit to carry something else -- the @@ -217,6 +336,9 @@ impl CoordField { /// /// Input: self, normalized; a 32-byte output buffer. /// Output: The value written to `bytes` as a 255-bit little-endian integer. + /// + /// Scope: both. In X25519 it is `encodeUCoordinate` (`x25519.rs:85`, `174`). + // TODO: Claude-generated, double-check pub fn encode_255(&self, bytes: &mut [u8; 32]) { let [w0, w1, w2, w3] = self.encode64_255(); let (a, b, c, d) = (w0.to_le_bytes(), w1.to_le_bytes(), w2.to_le_bytes(), w3.to_le_bytes()); @@ -244,6 +366,9 @@ impl CoordField { /// /// Input: self, normalized. /// Output: The value as four little-endian 64-bit words, least significant word first. + /// + /// Scope: both -- the packing half of [`Self::encode_255`]. + // TODO: Claude-generated, double-check const fn encode64_255(&self) -> [u64; 4] { let [x0, x1, x2, x3, x4] = self.0; let z0 = (x0 | x1 << 51) as u64; @@ -253,17 +378,47 @@ impl CoordField { [z0, z1, z2, z3] } - /// Decodes a 32-byte little-endian encoding of a field element. Bit 255 (the most significant - /// bit of the last byte) is masked off and ignored, since it is not part of a 255-bit field - /// element. Callers own the meaning of that bit: for X25519 it is unused and masking it is - /// required (see [`crate::x25519::decode_u_coordinate`]), for Ed25519 it carries the sign of x - /// (Section 5.1.3 of RFC 8032) and must be stripped off and handled separately. - /// Input: A 32-byte little-endian integer. - /// Output: The low 255 bits of that integer as a [`CoordField`]. + /// `decodeUCoordinate(u, 255)` as defined in Section 5 of RFC7748: + /// + /// ```text + /// def decodeUCoordinate(u, bits): + /// u_list = [ord(b) for b in u] + /// # Ignore any unused bits. + /// if bits % 8: + /// u_list[-1] &= (1<<(bits%8))-1 + /// return decodeLittleEndian(u_list, bits) + /// ``` + /// + /// The RFC's `bits` parameter is fixed at 255 by this type, so `bits % 8` is 7 and the mask + /// clears bit 255 -- the most significant bit of the final byte. Section 5 requires exactly + /// that: "When receiving such an array, implementations of X25519 (but not X448) MUST mask the + /// most significant bit in the final byte." + /// + /// Callers own the meaning of that bit: for X25519 it is unused, for Ed25519 it carries the + /// sign of x (Section 5.1.3 of RFC8032) and must be stripped off and handled separately. + /// + /// The RFC's `decodeLittleEndian` has no counterpart of its own -- it is folded into this + /// function and [`Self::decode64_255`]. + /// + /// Verified equal to `decodeUCoordinate(b, 255)` over 20,004 inputs, random plus edge cases. /// /// Note: Result is NOT normalized -- the value is in [0, 2^255), so the 19 encodings of - /// 2^255-19 .. 2^255-1 decode to values >= p. Call `normalize` before comparing or encoding. - pub const fn decode_255(bytes: &[u8; 32]) -> Self { + /// 2^255-19 .. 2^255-1 decode to values >= p. That is also required by Section 5: + /// "Implementations MUST accept non-canonical values and process them as if they had been + /// reduced modulo the field prime. The non-canonical values are 2^255 - 19 through 2^255 - 1 + /// for X25519". Call [`Self::normalize`] before comparing or encoding. + /// + /// Note on the name: Ed25519 needs this same byte-to-field decode for a *y*-coordinate, where + /// the masked bit is the sign of x rather than unused, so at that call site the RFC7748 name + /// will read wrong. It follows RFC7748 because that is the only caller today; revisit when + /// Ed25519 lands. + /// + /// Input: A 32-byte little-endian u-coordinate. + /// Output: The low 255 bits of that value as a [`CoordField`]. + /// + /// Scope: both x25519 and ed25519 + // TODO: Claude-generated, double-check + pub const fn decode_u_coordinate(bytes: &[u8; 32]) -> Self { let b = bytes; let x0 = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); let x1 = u64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]); @@ -276,12 +431,15 @@ impl CoordField { /// of a [`CoordField`], where the represented value is /// `z0 + z1*2^51 + z2*2^102 + z3*2^153 + z4*2^204`. Each limb is a 51-bit window of the input, /// straddling word boundaries; the `& M51` masks make every limb non-negative and leave headroom - /// for lazily-carried arithmetic. Bit 255 of the input is dropped -- see [`Self::decode_255`]. + /// for lazily-carried arithmetic. Bit 255 of the input is dropped -- see [`Self::decode_u_coordinate`]. /// Inverse of [`Self::encode64_255`]. /// Input: A 256-bit integer as four little-endian 64-bit words, least significant word first. /// Output: Bits 0..254 of that integer as a [`CoordField`]. /// - /// Note: Result is NOT normalized -- see the note on [`Self::decode_255`]. + /// Note: Result is NOT normalized -- see the note on [`Self::decode_u_coordinate`]. + /// + /// Scope: both -- the unpacking half of [`Self::decode_u_coordinate`]. + // TODO: Claude-generated, double-check const fn decode64_255(x: &[u64; 4]) -> Self { let [x0, x1, x2, x3] = *x; let z0 = x0 as i64 & M51; @@ -323,41 +481,85 @@ impl CoordField { // self.inv() // } - // TODO -- needs a docstring + /// Variable-time modular inverse, in place. + /// + /// Broken as of today: `inv_var`, `inv` and `inv_mut` are all commented out above, so this does + /// not compile. `x25519.rs:173` calls `inv()` for the ladder's final `x2 * z2^-1`, which makes + /// restoring inversion the one thing blocking X25519 end to end. + /// + /// Two routes back. The commented-out Fermat body, `pow_p_sub5_div8` then `sqr_n(3).mul(&x2)`, + /// computes `x^(p-2)` -- the exponent arithmetic checks out exactly, see + /// [`Self::pow_p_sub5_div8`] -- and needs roughly 254 squarings plus 11 multiplies, is inherently + /// constant time, and adds no dependency. The alternative, `modular::mod_odd_inverse`, is + /// asymptotically cheaper but is itself commented out in `crypto/math/src/modular.rs`, and that + /// crate is still the cargo template. + /// + /// Scope: Ed25519 for the variable-time flavour specifically; inversion itself is needed by both. // TODO -- why are we returning the same ref we were passed? + // TODO: Claude-generated, double-check pub fn inv_mut_var(&mut self) -> &mut Self { *self = self.inv_var(); self } - // TODO -- needs a docstring - // TODO Requires normalized self + /// Constant-time test for the value 1: `is_zero` over `(x0 ^ 1) | x1 | x2 | x3 | x4`. + /// + /// Requires normalized self -- only the canonical representative of 1 has limbs `[1, 0, 0, 0, 0]`. + /// + /// Scope: Ed25519 -- unused today. + // TODO: Claude-generated, double-check pub const fn is_one(&self) -> Condition { let [x0, x1, x2, x3, x4] = self.0; let x0 = x0 ^ 1; Condition::::is_zero(x0 | x1 | x2 | x3 | x4) } - // TODO -- needs a docstring - // TODO Requires normalized self + /// Variable-time [`Self::is_one`]. Only for values that are not secret. + /// + /// Requires normalized self. + /// + /// Scope: Ed25519 -- unused today. + // TODO: Claude-generated, double-check pub const fn is_one_var(&self) -> bool { self.is_one().to_bool() } - // TODO -- needs a docstring - // TODO Requires normalized self + /// Constant-time test for the value 0: `is_zero` over the OR of all five limbs. + /// + /// Requires normalized self -- an un-normalized zero can be represented as p, whose limbs are + /// not all zero. + /// + /// Scope: Ed25519. Reached today only through [`Self::is_zero_var`] from + /// [`Self::sqrt_ratio_var`]. + // TODO: Claude-generated, double-check pub const fn is_zero(&self) -> Condition { let [x0, x1, x2, x3, x4] = self.0; Condition::::is_zero(x0 | x1 | x2 | x3 | x4) } - // TODO -- needs a docstring - // TODO Requires normalized self + /// Variable-time [`Self::is_zero`]. Only for values that are not secret. + /// + /// Requires normalized self. + /// + /// Scope: Ed25519. Used by [`Self::sqrt_ratio_var`] to pick between the three square-root cases; + /// the branch is safe there because point decompression runs on public data. + // TODO: Claude-generated, double-check pub const fn is_zero_var(&self) -> bool { self.is_zero().to_bool() } - // TODO -- needs a docstring + /// Multiply self by rhs in GF(p), `p = 2^255 - 19`. + /// + /// Schoolbook 5x5: output limb `k` collects every `x[i] * y[j]` with `i + j == k`, plus every + /// pair with `i + j == k + 5`. The second set lands at weight `2^255` or above, and + /// `2^255 == 19 mod p`, so those terms are pre-scaled by 19 -- that is what `t1..t4` are. The 25 + /// products accumulate in `i128` (a 51-bit by 56-bit product would overflow `i64`) and + /// [`Self::reduce_products`] carries them back down to five `i64` limbs. + /// + /// Verified against reference mod-p arithmetic over 20,000 random pairs. + /// + /// Scope: both. + // TODO: Claude-generated, double-check pub const fn mul(&self, rhs: &Self) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { @@ -377,7 +579,15 @@ impl CoordField { ) } - // TODO -- needs a docstring + /// Multiply self by a small unsigned scalar in GF(p). + /// + /// A single-limb multiply, so unlike [`Self::mul`] there are no wrap-around terms to pre-scale by + /// 19 -- each output limb takes exactly one product. The `19` fold still happens, but only for + /// whatever carries out of the top limb inside [`Self::reduce_products`]. + /// + /// Scope: X25519. This is the `a24 * E` of the Section 5 ladder, with + /// `a24 = (486662 - 2) / 4 = 121665` (`x25519.rs:110`, `148`). + // TODO: Claude-generated, double-check pub const fn mul_u32(&self, rhs: u32) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { @@ -390,42 +600,122 @@ impl CoordField { Self::reduce_products(m(x0, y0), m(x1, y0), m(x2, y0), m(x3, y0), m(x4, y0)) } - /// Negates the components of self + /// Negates self, component-wise. + /// + /// Negating every limb negates the value, since the limb weights `2^(51*i)` are all positive. + /// Lazily reduced, and note the result has negative limbs, which is legal in this representation + /// but means it is *not* normalized -- [`Self::normalize`] must run before any encode or + /// comparison. + /// + /// Scope: Ed25519 -- unused today. Wanted for point negation and for the conditional negation of + /// signed-digit fixed-base tables; the X25519 ladder never negates. + // TODO: Claude-generated, double-check pub const fn negate(&self) -> Self { let [x0, x1, x2, x3, x4] = self.0; Self([-x0, -x1, -x2, -x3, -x4]) } + /// [`Self::negate`] in place. + /// + /// Scope: Ed25519 -- unused today; see [`Self::negate`]. + // TODO: Claude-generated, double-check pub fn negate_mut(&mut self) -> &mut Self { *self = self.negate(); self } + /// Construct a [`CoordField`] from five raw limbs, value + /// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204`. + /// + /// No normalization and no range check: the caller owns the invariant that the limbs are inside + /// the magnitude bound the reduction chains assume. + /// + /// Scope: both -- unused today. // TODO Module consumers should instead have a method to construct from [u64; 4] (or indeed a Nat<4>) + // TODO: Claude-generated, double-check pub const fn new(x0: i64, x1: i64, x2: i64, x3: i64, x4: i64) -> Self { Self([x0, x1, x2, x3, x4]) } + /// Reduce self to the unique canonical representative of its residue class: limbs all in + /// `[0, 2^51)` and the value in `[0, p)`. + /// + /// Two [`Self::reduce`] passes with `y` and then `-y`, where `y` is bit 254 of the value (bit 50 + /// of limb `x4`, since `x4` sits at weight `2^204`). Each pass adds `19 * y` at the bottom + /// without a matching removal at the top, so neither pass alone is congruent mod p -- but the + /// `+19y` and `-19y` cancel exactly, leaving `value - (c + c') * p` for the two top-carry folds + /// `c`, `c'`. + /// + /// The nudge is what implements Section 5 of RFC7748's "Implementations MUST accept non-canonical + /// values and process them as if they had been reduced modulo the field prime. The non-canonical + /// values are 2^255 - 19 through 2^255 - 1". Checked empirically: a plain `reduce(0).reduce(0)` + /// returns a non-canonical result for *every* value in `p ..= p + 18` -- exactly that range -- + /// whereas this `reduce(y).reduce(-y)` is exact on all of `[0, 2^255)`, which is the entire range + /// [`Self::decode_u_coordinate`] can produce. It first fails at `2^255`, outside the input domain. + /// + /// Requires limbs inside [`Self::reduce`]'s magnitude bound. Idempotent. + /// + /// Scope: both. + // TODO: Claude-generated, double-check pub const fn normalize(&self) -> Self { let y = (self.0[4] >> (51 - 1)) & 1; self.reduce(y).reduce(-y) } + /// [`Self::normalize`] in place. + /// + /// Scope: both -- unused today; call sites use [`Self::normalize`] directly + /// (`x25519.rs:174`). + // TODO: Claude-generated, double-check pub fn normalize_mut(&mut self) -> &mut Self { *self = self.normalize(); self } + /// The multiplicative identity, 1, in canonical form. + /// + /// Scope: both. In X25519 it seeds `z_2` and `x_3` of the Section 5 ladder (`x25519.rs:128`, + /// `129`). + // TODO: Claude-generated, double-check pub const fn one() -> Self { Self([1, 0, 0, 0, 0]) } + /// The least significant bit of the value. + /// + /// Requires normalized self, and this is the sharpest edge in the group: p is odd, so a value in + /// `[p, 2^255)` reports the parity of `v` rather than of `v - p` -- the answer is wrong, not + /// merely non-canonical. + /// + /// Scope: Ed25519. This is the sign bit of x in an Ed25519 point encoding, Section 5.1.3 of + /// RFC8032. X25519 has no use for it: Section 5 of RFC7748 requires bit 255 of a u-coordinate to + /// be masked off, not populated. // TODO Better name -- is_odd() ? + // TODO: Claude-generated, double-check pub const fn parity(&self) -> u8 { (self.0[0] & 1) as u8 } + /// Returns `(x^3, x^((p-5)/8))` for `x = self`, by a fixed addition chain. + /// + /// `(p - 5) / 8 == 2^252 - 3` for `p = 2^255 - 19`, which in binary is 250 ones, a zero, then a + /// one -- the `FFFF..FD` of the comment below. The chain reaches it in 10 steps by doubling runs + /// of 1-bits. Both return values are exponent-verified. + /// + /// Beware the naming: the locals count *consecutive 1-bits*, not exponents. `x2` is `x^3` + /// (`0b11`, two ones), `x3` is `x^7`, `x5` is `x^31`, and `x250` is `x^(2^250 - 1)`. It reads + /// like an off-by-one bug and is not one. + /// + /// Both consumers need the pair: + /// * inversion, as `z.sqr_n(3).mul(&x2)` -- exponent `8 * (2^252 - 3) + 3 == 2^255 - 21 == p - 2`, + /// i.e. Fermat. + /// * [`Self::sqrt_ratio_var`], which uses `x^((p-5)/8)` directly as the `p == 5 mod 8` square-root + /// candidate. + /// + /// Scope: both -- it is the engine under the field inversion X25519 needs, and under Ed25519's + /// point decompression. // TODO Addition chain macro/function + // TODO: Claude-generated, double-check const fn pow_p_sub5_div8(&self) -> (Self, Self) { // TODO -- where is this from? // z = x^((p-5)/8) = x^FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD @@ -448,7 +738,28 @@ impl CoordField { (x2, z) } - // TODO -- what is this and where is it from? + /// One carry pass that additionally subtracts `(x4 >> 51)` multiples of p and adds `19 * y` at + /// the bottom. Not a standalone reduction -- see the warning below. + /// + /// Bits at or above `2^255` (the `x4 >> 51` carry) are removed from the top and re-added at the + /// bottom scaled by 19, which subtracts a multiple of `p = 2^255 - 19`. The `y` argument is added + /// into that same fold, so it contributes `19 * y` at the bottom with *no* matching removal at + /// the top. The verified identity is: + /// + /// ```text + /// value(reduce(x, y)) == value(x) - (x4 >> 51) * p + 19 * y + /// ``` + /// + /// Warning: a single `reduce` is therefore **not** congruent to its input mod p unless `y == 0`. + /// The `19 * y` is a deliberate nudge that only cancels across the pair of calls in + /// [`Self::normalize`], and this function exists to serve that pair -- it is not a general-purpose + /// reduction. + /// + /// Requires limbs inside the magnitude bound the carry chain assumes: `t5 * 19 + x0` must not + /// overflow `i64`. + /// + /// Scope: both. + // TODO: Claude-generated, double-check const fn reduce(&self, y: i64) -> Self { let [x0, x1, x2, x3, x4] = self.0; @@ -475,7 +786,19 @@ impl CoordField { Self([z0, z1, z2, z3, z4]) } - // TODO -- what is this and where is it from? + /// Carry the five `i128` product accumulators produced by [`Self::mul`], [`Self::sqr`] and + /// [`Self::mul_u32`] back down into five `i64` limbs. + /// + /// One pass: `split` peels each accumulator into its own 51 bits and the carry above them, the + /// carries move up one limb each, and the carry out of the top (`c5`, at weight `2^255`) folds + /// into limb 0 scaled by 19 because `2^255 == 19 mod p`. A second short carry chain over + /// `u0..u3` absorbs the fold. + /// + /// The result is congruent mod p with limbs near 51 bits, but is *not* normalized -- it may still + /// exceed p. Call [`Self::normalize`] before encoding or comparing. + /// + /// Scope: both. + // TODO: Claude-generated, double-check #[inline] const fn reduce_products(p0: i128, p1: i128, p2: i128, p3: i128, p4: i128) -> Self { #[inline(always)] @@ -503,8 +826,16 @@ impl CoordField { ]) } - // TODO -- what is this and where is it from? - // TODO -- why is it pub and now pub(crate) + /// Square self in GF(p). + /// + /// Same shape as [`Self::mul`], but a squaring's cross terms come in equal pairs, so pre-doubling + /// `x0..x3` into `y0..y3` lets one product stand for two and drops the count from 25 to 15. The + /// `19` fold for the `i + j >= 5` terms is folded into `t3`, `t4` as before. + /// + /// Verified against reference mod-p arithmetic over 20,000 random values. + /// + /// Scope: both. + // TODO: Claude-generated, double-check pub const fn sqr(&self) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { @@ -524,15 +855,25 @@ impl CoordField { ) } - // TODO -- what is this and where is it from? + /// [`Self::sqr`] in place. + /// + /// Scope: both. Used throughout the Section 5 ladder (`x25519.rs:144`, `145`, `152`). // TODO -- why is it pub and now pub(crate) + // TODO: Claude-generated, double-check pub fn sqr_mut(&mut self) -> &mut Self { *self = self.sqr(); self } - // TODO -- what is this and where is it from? + /// Square self `n` times, i.e. raise to the `2^n`. + /// + /// The run-length primitive for the addition chain in [`Self::pow_p_sub5_div8`]: an exponent + /// written as runs of 1-bits is evaluated as alternating `sqr_n` (shift the accumulated bits up) + /// and `mul` (append another run). + /// + /// Scope: both -- it is how the field inversion is reached. // TODO -- why is it pub and now pub(crate) + // TODO: Claude-generated, double-check pub const fn sqr_n(&self, mut n: usize) -> Self { let mut z = *self; while n > 0 { @@ -542,8 +883,26 @@ impl CoordField { z } - // TODO -- what is this and where is it from? + /// Variable-time square root of the ratio `u / v`: returns `Some(x)` with `x^2 == u / v` if that + /// ratio is a quadratic residue, else `None`. + /// + /// The standard `p == 5 mod 8` method (p mod 8 is 5, checked), arranged to need no inversion: the + /// candidate is `x = u * v^3 * (u * v^7)^((p-5)/8)`, and `v * x^2` is then compared against `+u` + /// and `-u` to sort the three cases. If `v * x^2 == -u`, the true root is `x` times a square root + /// of -1, which is the `ROOT_NEG_ONE` constant -- verified to square to `-1 mod p`, and to equal + /// `2^((p-1)/4)`. + /// + /// Verified over 4,000 random `(u, v)`: every returned root satisfies `x^2 == u / v`, and every + /// `None` is a genuine non-residue. + /// + /// Variable time by construction, via [`Self::is_zero_var`]. That is acceptable for point + /// decompression, which runs on public data, and it is why the name carries the `_var` suffix. + /// + /// Scope: Ed25519. Point decompression, Section 5.1.3 of RFC8032 -- recovering x from y. The + /// X25519 ladder never takes a square root; Section 5 of RFC7748 works from the u-coordinate + /// alone and never recovers v. // TODO -- why is it pub and now pub(crate) + // TODO: Claude-generated, double-check pub const fn sqrt_ratio_var(u: &Self, v: &Self) -> Option { let uv = u.mul(v); let v2 = v.sqr(); @@ -575,31 +934,51 @@ impl CoordField { } /// Subtract rhs from self, component-wise. + /// + /// Lazily reduced, exactly as [`Self::add`]: no borrow propagation, so limbs may go negative and + /// the result is congruent mod p but not normalized. Negative limbs are fine here -- + /// [`Self::reduce_products`] and [`Self::reduce`] both use arithmetic (sign-propagating) shifts, + /// so a negative limb borrows correctly on the next reduction. + /// + /// Scope: both. In X25519 it is the `E = AA - BB` of the Section 5 ladder (`x25519.rs:109`, + /// `147`). // TODO -- why is this pub and not pub(crate) ? - // TODO -- how does this handle wrapping / overflow? + // TODO: Claude-generated, double-check pub const fn sub(&self, rhs: &Self) -> Self { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; Self([x0 - y0, x1 - y1, x2 - y2, x3 - y3, x4 - y4]) } - /// Subtract CoordField::One from self, component-wise, and return a new CoordField. + /// Subtract one from self, i.e. subtract 1 from limb `x0`, returning a new [`CoordField`]. + /// + /// Mirror of [`Self::add_one`]; if `x0` was zero the limb simply goes negative, which the next + /// reduction resolves. + /// + /// Scope: Ed25519 -- unused today; the `(u - 1)/(u + 1)` birational map of Section 4.1 of + /// RFC7748 is what wants it. // TODO -- why is this pub and not pub(crate) ? - // TODO -- how does this handle wrapping / overflow? + // TODO: Claude-generated, double-check pub const fn sub_one(&self) -> Self { let [x0, x1, x2, x3, x4] = self.0; Self([x0 - 1, x1, x2, x3, x4]) } - /// Subtract CoordField::One from self, component-wise, in-place. + /// [`Self::sub_one`] in place. + /// + /// Scope: Ed25519 -- unused today; see [`Self::sub_one`]. // TODO -- do we really need both versions of this? // TODO -- why is this pub and not pub(crate) ? - // TODO -- how does this handle wrapping / overflow? + // TODO: Claude-generated, double-check pub fn sub_one_mut(&mut self) -> &mut Self { self.0[0] -= 1; self } + /// The additive identity, 0, in canonical form. + /// + /// Scope: both. In X25519 it seeds `z_3` of the Section 5 ladder (`x25519.rs:130`). + // TODO: Claude-generated, double-check pub const fn zero() -> Self { Self([0; 5]) } @@ -642,13 +1021,13 @@ mod curve25519_tests { [0x7308909F02085, 0x69B8FB16E8A94, 0x4EAFC356BDCFA, 0x275FA0D1C1306, 0x6A4E9BAA8EA9A]; #[test] - fn decode_255_known_value() { - assert_eq!(CoordField::decode_255(&KNOWN_VALUE).0, KNOWN_LIMBS); + fn decode_u_coordinate_known_value() { + assert_eq!(CoordField::decode_u_coordinate(&KNOWN_VALUE).0, KNOWN_LIMBS); // Bit 255 is not part of the field element, so setting it must not change the result. let mut bit_255_set = KNOWN_VALUE; bit_255_set[31] |= 0x80; - assert_eq!(CoordField::decode_255(&bit_255_set).0, KNOWN_LIMBS); + assert_eq!(CoordField::decode_u_coordinate(&bit_255_set).0, KNOWN_LIMBS); } #[test] diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index 30a1fb435..4c05c73cc 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -18,9 +18,7 @@ // TODO -- #![forbid(missing_docs)] // Add back at the end -extern crate alloc; extern crate alloc; mod curve25519; -mod rfc7748; pub mod x25519; diff --git a/crypto/ed/src/rfc7748.rs b/crypto/ed/src/rfc7748.rs deleted file mode 100644 index 83f185424..000000000 --- a/crypto/ed/src/rfc7748.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! This is a from-scratch implementation of RFC7748, not starting from the bc-rust-nursery. -//! TODO -- Unclear if this will end up being better or worse than the bc-rust-nursery version. - -/// "The constant a24 is -/// (486662 - 2) / 4 = 121665 for curve25519/X25519" -// Name matches RFC -#[allow(non_upper_case_globals)] -const a24: i32 = 121665; - -#[allow(non_upper_case_globals)] -const p: 2^255 - 19 - -/// From RFC7748 as: -/// def decodeUCoordinate(u, bits): -/// u_list = [ord(b) for b in u] -/// # Ignore any unused bits. -/// if bits % 8: -/// u_list[-1] &= (1<<(bits%8))-1 -/// return decodeLittleEndian(u_list, bits) -// todo -- possibly `bits` should be a ? -fn decode_u_coordinate(u: &[u8], bits: usize) -> UCoordinate25519 { - let mut u_list = [0u8; 32]; - for (i, &byte) in u.iter().enumerate() { - u_list[i] = byte; - } - // Ignore any unused bits. - if bits % 8 != 0 { - u_list[31] &= (1 << (bits % 8)) - 1; - } - - u_list -} - -/// From RFC7748 as: -/// def encodeUCoordinate(u, bits): -/// u = u % p -/// return ''.join([chr((u >> 8*i) & 0xff) -/// for i in range((bits+7)/8)]) -fn encode_u_coordinate(u: UCoordinate25519, bits: usize) -> [u8; 32] { - let mut encoded = [0u8; 32]; - for i in 0..(bits + 7) / 8 { - encoded[i] = (u[i] >> (8 * i)) & 0xff; - } - - encoded -} - -/// From RFC7748 as: -/// def decodeScalar25519(k): -/// k_list = [ord(b) for b in k] -/// k_list[0] &= 248 -/// k_list[31] &= 127 -/// k_list[31] |= 64 -/// return decodeLittleEndian(k_list, 255) -/// -/// "For X25519, in -/// order to decode 32 random bytes as an integer scalar, set the three -/// least significant bits of the first byte and the most significant bit -/// of the last to zero, set the second most significant bit of the last -/// byte to 1 and, finally, decode as little-endian. This means that the -/// resulting integer is of the form 2^254 plus eight times a value -/// between 0 and 2^251 - 1 (inclusive)." -fn decode_scalar_25519(k: Scalar25519) -> [u8; 32] { - let mut k_list = k.clone(); - k_list[0] &= 248; - k_list[31] &= 127; - k_list[31] |= 64; - - k_list -} - -// "Here, the "bits" parameter should be set to 255 for X25519 and 448 for X448:" - -fn x25519(k: Scalar25519, u: UCoordinate25519) -> UCoordinate25519 { - xdh::<255, Scalar25519, UCoordinate25519>(k, u) -} - -// TODO -- need to define arithmetic ops for Scalar25519 - -#[allow(non_snake_case)] -fn xdh(k: Scalar, u: UCoordinate) -> UCoordinate { - let x_1 = u.clone(); - let mut x_2 = 1; - let mut z_2 = 0; - let mut x_3 = u.clone(); - let mut z_3 = 1; - let mut swap = 0; - - for t in (0..bits - 1).rev() { - let k_t = (k >> t) & 1; - swap ^= k_t; - - // Conditional swap - (x_2, x_3) = cswap(swap, x_2, x_3); - (z_2, z_3) = cswap(swap, z_2, z_3); - swap = k_t; - - let A = x_2 + z_2; - let AA = A ^ 2; - let B = x_2 - z_2; - let BB = B ^ 2; - let E = AA - BB; - let C = x_3 + z_3; - let D = x_3 - z_3; - let DA = D * A; - let CB = C * B; - x_3 = (DA + CB) ^ 2; - z_3 = x_1 * (DA - CB) ^ 2; - x_2 = AA * BB; - z_2 = E * (AA + a24 * E); - } - - // Conditional swap; see text below. - (x_2, x_3) = cswap(swap, x_2, x_3); - (z_2, z_3) = cswap(swap, z_2, z_3); - - x_2 * (z_2 ^ (p - 2)) -} - -fn cswap(swap: i32, x_2: i32, x_3: i32) -> (i32, i32) { - // "Where mask(swap) is the all-1 or all-0 word of the same length as x_2 - // and x_3, computed, e.g., as mask(swap) = 0 - swap." - fn mask(swap: i32) -> i32 { - 0 - swap - } - - let dummy = mask(swap) & (x_2 ^ x_3); - (x_2 ^ dummy, x_3 ^ dummy) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cswap() { - assert_eq!(cswap(0, 1, 2), (1, 2)); - assert_eq!(cswap(1, 1, 2), (2, 1)); - } -} diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 0f629ab22..3b5c809c8 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -1,5 +1,4 @@ use bouncycastle_utils::ct::Condition; -use rand::RngCore; use crate::curve25519::CoordField; @@ -49,7 +48,43 @@ fn decode_u32(bs: &[u8]) -> u32 { (bs[0] as u32) | (bs[1] as u32) << 8 | (bs[2] as u32) << 16 | (bs[3] as u32) << 24 } -fn decode_scalar(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { +/// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: +/// +/// ```text +/// def decodeScalar25519(k): +/// k_list = [ord(b) for b in k] +/// k_list[0] &= 248 +/// k_list[31] &= 127 +/// k_list[31] |= 64 +/// return decodeLittleEndian(k_list, 255) +/// ``` +/// +/// Section 5 gives the same rule in prose: "set the three least significant bits of the first +/// byte and the most significant bit of the last to zero, set the second most significant bit of +/// the last byte to 1 and, finally, decode as little-endian. This means that the resulting +/// integer is of the form 2^254 plus eight times a value between 0 and 2^251 - 1 (inclusive)." +/// +/// That last sentence is the invariant [`scalar_mult`] is built on, and it needs both halves of +/// it. The `2^254 +` is why the ladder can start at bit 253 against a pre-swapped initial state; +/// the `8 *` is why its last three iterations collapse into three [`point_double`] calls with no +/// trailing conditional swap. +/// +/// Deviation from the RFC: the RFC masks bytes and then decodes, whereas this decodes into +/// four-byte words first and masks the equivalent word positions. `k_list[0] &= 248` becomes +/// `n[0] &= 0xFFFFFFF8` -- the same low three bits, since `n[0]` is the first four bytes -- and +/// `k_list[31] &= 127` / `|= 64` become `n[7] &= 0x7FFFFFFF` / `|= 0x40000000`, the same top two +/// bits, since `n[7]` is the last four bytes. Verified equal to `decodeScalar25519` over 20,004 +/// inputs, random plus edge cases. +/// +/// The `25519` in the name is load-bearing: `decodeScalar448` clears two low bits rather than +/// three, and sets bit 447 (Section 5). +/// +/// Input: A 32-byte little-endian scalar. +/// Output: The decoded scalar, written to `n` as eight little-endian 32-bit words. +/// +/// Scope: X25519. +// TODO: Claude-generated, double-check +fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { for (i, n_i) in n.iter_mut().enumerate() { let pos = i * 4; *n_i = decode_u32(&k[pos..(pos + 4)]); @@ -60,19 +95,6 @@ fn decode_scalar(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { n[7] |= 0x40000000_u32; } -/// `decodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. -/// The most significant bit of the final byte is unused by X25519 and MUST be masked off before -/// decoding, so that u-coordinates carrying a sign bit in that position (as Ed25519 encodings do) -/// stay compatible. Non-canonical values -- u >= p, i.e. the encodings of 2^255-19 .. 2^255-1 -- -/// MUST be accepted rather than rejected, which is why the result is deliberately left -/// un-normalized here; the ladder in [`scalar_mult`] reduces it as it goes. -/// Input: A 32-byte little-endian u-coordinate. -/// Output: The low 255 bits of that value as a [`CoordField`]. -// TODO -- added by Claude; need to check and use. -fn decode_u_coordinate(u: &[u8; POINT_SIZE]) -> CoordField { - CoordField::decode_255(u) -} - /// `encodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. /// The RFC's `u = u % p` step is [`CoordField::normalize`]: an X25519 output must be the canonical /// encoding of its residue class, even though non-canonical values MUST be accepted on the way in @@ -85,14 +107,14 @@ fn encode_u_coordinate(u: &CoordField, r: &mut [u8; POINT_SIZE]) { u.normalize().encode_255(r); } -// TODO make this match the style of MLDSA keygen -pub fn generate_private_key(random: &mut dyn RngCore, k: &mut [u8; SCALAR_SIZE]) { - random.fill_bytes(&mut k[..]); - - k[0] &= 0xF8; - k[SCALAR_SIZE - 1] &= 0x7F; - k[SCALAR_SIZE - 1] |= 0x40; -} +// // TODO make this match the style of MLDSA keygen +// pub fn generate_private_key(random: &mut dyn RngCore, k: &mut [u8; SCALAR_SIZE]) { +// random.fill_bytes(&mut k[..]); +// +// k[0] &= 0xF8; +// k[SCALAR_SIZE - 1] &= 0x7F; +// k[SCALAR_SIZE - 1] |= 0x40; +// } // TODO make this match the style of MLDSA keygen pub fn generate_public_key(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { @@ -101,7 +123,7 @@ pub fn generate_public_key(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { // TODO: move to curve25519.rs ? fn point_double(x: &mut CoordField, z: &mut CoordField) { - let (mut a, mut b) = x.apm(z); + let (mut a, mut b) = x.sum_and_difference(z); a.sqr_mut(); b.sqr_mut(); *x = a.mul(&b); @@ -117,13 +139,39 @@ pub fn precompute() { // curve25519::precompute(); } -// TODO -- "For X25519, the unused, most significant bit MUST be zero." - -fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], r: &mut [u8; POINT_SIZE]) { - let mut n = [0_u32; 8]; - decode_scalar(k, &mut n); +// TODO -- "For X25519, the unused, most significant bit MUST be zero." -- add test case or debug_assert. + +/// `X25519(k, u)` as defined in Section 5 of RFC7748: the Montgomery ladder, computing scalar +/// multiplication on curve25519 from a u-coordinate alone. +/// +/// The ladder is constant time with respect to `k`: the iteration count is fixed and the only +/// scalar-dependent step is [`CoordField::cswap`]. +/// +/// # Modifications from the RFC +/// Modified to fold the RFC's first ladder iteration into the initial state, to replace its last +/// three iterations with [`point_double`] calls, and to cycle the RFC's named intermediates +/// through a fixed set of registers rather than materialise them. +/// +/// # Inputs +/// * `k`, a 32-byte scalar +/// * `u`, a 32-byte little-endian u-coordinate +/// * `out`, a 32-byte output buffer. +/// Output: The canonical little-endian encoding of the u-coordinate of `k * u`, written to `out`. +// TODO -- the Section 5.2 and Section 6.1 test vectors are not exercised anywhere in this crate +// yet. The structure here was checked against them out-of-tree, which is not the same +// thing as a test. +// TODO: Claude-generated, double-check +fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT_SIZE]) { + let mut n = [0u32; 8]; + decode_scalar_25519(k, &mut n); + + // Pre-condition: + // The ladder below is only correct for scalars of the form 2^254 + 8m, 0 <= m <= 2^251 - 1 + // which should be enforced by a correct implementation of decode_scalar_25519. + debug_assert_eq!(1, n[7] >> 30); + debug_assert_eq!(0, n[0] & 7); - let x1 = CoordField::decode_255(u); + let x1 = CoordField::decode_u_coordinate(u); let mut x2 = x1; let mut z2 = CoordField::one(); let mut x3 = CoordField::one(); @@ -132,46 +180,94 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], r: &mut [u8; POINT_S let mut t1; let mut t2; - debug_assert_eq!(1, n[7] >> 30); - - let mut bit: usize = 253; - let mut cond_swap = Condition::::from_bool::(); - loop { - (t1, x3) = x3.apm(&z3); - (z3, x2) = x2.apm(&z2); + let mut swap = Condition::::from_bool_const::(); + + // Deviation from the RFC: + // RFC7748 §5 runs `For t = bits-1 down to 0`, i.e. t = 254 down to 0, with the conditional + // swap at the top of the body and the arithmetic after it. This loop deviates in three ways, + // all of them consequences of the scalar's form -- see the pre-condition above: + // + // 1. Rotated body. The arithmetic comes first here and the swap last. + // 2. t = 254 is not in the loop because bit 254 is always set, so that iteration's swap is known in + // advance: the registers above are seeded with the RFC's x_2 = 1, z_2 = 0, x_3 = u, + // z_3 = 1 *after* it has been applied, and `swap` starts at k_254. So the loop starts at 253 and not 254. + // 3. t = 2, 1, 0 are not in the loop because bits 0..2 are always clear, so those iterations reduce + // to doubling the (x_2, z_2) branch and their swaps are no-ops. They become the three + // point_double calls after the loop, and the RFC's post-loop cswap is dropped for the same + // reason -- the debug_assert below is what checks it would have been a no-op. + for bit in (2..=253_usize).rev() { + // This section is the same core algorithm as in RFC7748 §5, but uses a different variable + // naming convention in order to more efficiently reuse memory. + // Comment style: + // , -> + + // C = x_3 + z_3, -> t1 + // D = x_3 - z_3, -> x3 + (t1, x3) = x3.sum_and_difference(&z3); + // A = x_2 + z_2, -> z3 + // B = x_2 - z_2, -> x2 + // Must follow the line above, not precede it: that one still reads the incoming z_3, + // which this one overwrites. + (z3, x2) = x2.sum_and_difference(&z2); + // CB = C * B, -> t1 t1 = t1.mul(&x2); + // DA = D * A, -> x3 x3 = x3.mul(&z3); + // AA = A^2, -> z3 z3.sqr_mut(); + // BB = B^2, -> x2 x2.sqr_mut(); + // E = AA - BB, -> t2 t2 = z3.sub(&x2); + // z_2 = E * (AA + a24 * E), -> z2 + // Deviation from the RFC: written as E * (BB + C_A24 * E), an equivalent pairing of the + // constant with the other operand. See C_A24's declaration. + // The incoming z_2 died on the `sum_and_difference` above, which is what frees this slot. z2 = t2.mul_u32(C_A24).add(&x2).mul(&t2); + // x_2 = AA * BB, -> x2 x2 = x2.mul(&z3); - (x3, z3) = t1.apm(&x3); + // x_3 = (DA + CB)^2, -> x3 + // z_3 = x_1 * (DA - CB)^2, -> z3 + // The function `sum_and_difference()` computes `(DA + CB) and (DA - CB)` is a single + // operation, allowing these lines to be combined. + // Deviation from the RFC: yields CB - DA, negated. Harmless as both lines below square it. + (x3, z3) = t1.sum_and_difference(&x3); + // (completes x_3) x3.sqr_mut(); + // (completes z_3) z3 = z3.sqr().mul(&x1); - let cond_bit = Condition::::is_bit_set(n[bit >> 5] as i64, (bit & 0x1F) as i64); - cond_swap ^= cond_bit; - CoordField::cswap(cond_swap, &mut x2, &mut x3); - CoordField::cswap(cond_swap, &mut z2, &mut z3); - cond_swap = cond_bit; - - if bit < 3 { - break; - }; - bit -= 1; + // The swap block is a statement-for-statement transcription, just its placement within the + // loop has been changed. + // This differs from the ladder presentation in the RFC because it implements the + // constant-time cswap recommendation at the bottom of §5 using an all-ones/all-zeros + // mask and the Condition type. + + // k_t = (k >> t) & 1 + // > Presentation differs due to decoding k into n, and the bit-selecting Condition operator. + let k_bit = Condition::::is_bit_set(n[bit >> 5] as i64, (bit & 0x1F) as u32); + // swap ^= k_t + swap ^= k_bit; + // (x_2, x_3) = cswap(swap, x_2, x_3) + // > swaps in-place + CoordField::cswap(swap, &mut x2, &mut x3); + // (z_2, z_3) = cswap(swap, z_2, z_3) + // > swaps in-place + CoordField::cswap(swap, &mut z2, &mut z3); + // swap = k_t + swap = k_bit; } - debug_assert!(!cond_swap.to_bool_var()); + debug_assert!(!swap.to_bool()); for _ in 0..3 { point_double(&mut x2, &mut z2); } x2 = x2.mul(&z2.inv()); - x2.normalize().encode_255(r); + x2.normalize().encode_255(out); } fn scalar_mult_base(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { @@ -182,7 +278,7 @@ fn scalar_mult_base(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { // TODO // let (y, z) = curve25519::scalar_mult_base_yz(k); - // let (t, u) = z.apm(&y); + // let (t, u) = z.sum_and_difference(&y); // let v = t.mul(&u.inv()); // v.normalize().encode(r); } diff --git a/crypto/math/Cargo.toml b/crypto/math/Cargo.toml new file mode 100644 index 000000000..ea1f22701 --- /dev/null +++ b/crypto/math/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "bouncycastle-math" +edition.workspace = true +version.workspace = true + +[dependencies] diff --git a/crypto/math/src/lib.rs b/crypto/math/src/lib.rs new file mode 100644 index 000000000..b5d88072d --- /dev/null +++ b/crypto/math/src/lib.rs @@ -0,0 +1,16 @@ +pub mod modular; + +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} From a0aeddf4c8b2a8b7c93cc7090fbc9714098a5868 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Wed, 26 Aug 2026 12:53:49 -0500 Subject: [PATCH 05/26] progress on 25519 --- crypto/ed/src/curve25519.rs | 98 ++++++++++--------- ...5519_tests.rs => x25519_tests.rs.disabled} | 0 2 files changed, 53 insertions(+), 45 deletions(-) rename crypto/ed/tests/{x25519_tests.rs => x25519_tests.rs.disabled} (100%) diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index 8524e4c76..9c6c5e393 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -450,51 +450,59 @@ impl CoordField { Self([z0, z1, z2, z3, z4]) } - // // TODO -- needs a docstring - // pub fn inv(&self) -> Self { - // // let (x2, t) = self.pow_p_sub5_div8(); - // // t.sqr_n(3).mul(&x2) - // - // #[inline(always)] - // fn is_zero(x: &[u64; 4]) -> Condition { - // // todo -- we'll get this when we merge /pull/63 and then rebase this branch on top - // Condition::::is_zero(x[0] | x[1] | x[2] | x[3]) - // } - // - // let u = self.normalize().encode64_255(); - // let mut v = [0; 4]; - // let success = modular::mod_odd_inverse(&P64, &u, &mut v); - // debug_assert!((success | (is_zero(&u) & is_zero(&v))).to_bool_var()); - // Self::decode64_255(&v) - // } - - // // TODO -- needs a docstring - // // TODO -- why are we returning the same ref we were passed? - // pub fn inv_mut(&mut self) -> &mut Self { - // *self = self.inv(); - // self - // } - - // // TODO -- needs a docstring - // pub fn inv_var(&self) -> Self { - // // TODO Use a vartime optimized version of modular::mod_odd_inverse - // self.inv() - // } - - /// Variable-time modular inverse, in place. - /// - /// Broken as of today: `inv_var`, `inv` and `inv_mut` are all commented out above, so this does - /// not compile. `x25519.rs:173` calls `inv()` for the ladder's final `x2 * z2^-1`, which makes - /// restoring inversion the one thing blocking X25519 end to end. - /// - /// Two routes back. The commented-out Fermat body, `pow_p_sub5_div8` then `sqr_n(3).mul(&x2)`, - /// computes `x^(p-2)` -- the exponent arithmetic checks out exactly, see - /// [`Self::pow_p_sub5_div8`] -- and needs roughly 254 squarings plus 11 multiplies, is inherently - /// constant time, and adds no dependency. The alternative, `modular::mod_odd_inverse`, is - /// asymptotically cheaper but is itself commented out in `crypto/math/src/modular.rs`, and that - /// crate is still the cargo template. - /// - /// Scope: Ed25519 for the variable-time flavour specifically; inversion itself is needed by both. + /// Multiplicative inverse in GF(p), by Fermat's little theorem: `x^(p-2) == x^-1`. + /// + /// [`Self::pow_p_sub5_div8`] returns `(x^3, x^((p-5)/8))`. Raising the second to the 8th and + /// multiplying by the first gives exponent `8 * (2^252 - 3) + 3 == 2^255 - 21 == p - 2`. + /// Costs 254 squarings and 11 multiplies. + /// + /// Constant time: a fixed addition chain, with no data-dependent branch or index. + /// + /// `inv(0) == 0`, since `0^(p-2) == 0`. That is not a case to guard against -- X25519 depends + /// on it. A small-order input point drives the ladder's `z_2` to zero, and Section 6.1 of + /// RFC7748 requires the resulting all-zero output to be detectable: "the X25519 function + /// produces that value if it operates on an input corresponding to a point with small order". + /// Returning zero here is what carries that through the final `x_2 * z_2^-1`. + /// + /// Verified against a reference modular inverse over 305 values, edge cases included. + /// + /// Scope: both x25519 and ed25519. + // TODO -- `modular::mod_odd_inverse` would be asymptotically cheaper, and P64 above is the + // constant it wants, but crypto/math is still the cargo template. Only worth revisiting + // if this shows up in a profile -- it is one call per scalar_mult. + // TODO: Claude-generated, double-check + pub const fn inv(&self) -> Self { + let (x_cubed, t) = self.pow_p_sub5_div8(); + t.sqr_n(3).mul(&x_cubed) + } + + /// [`Self::inv`] in place. + /// + /// Scope: both x25519 and ed25519. + // TODO -- why are we returning the same ref we were passed? + // TODO: Claude-generated, double-check + pub fn inv_mut(&mut self) -> &mut Self { + *self = self.inv(); + self + } + + /// Variable-time multiplicative inverse. + /// + /// Currently just [`Self::inv`], which is constant time -- so this is correct and safe, merely + /// not faster. The separate name reserves the slot: for a public operand, an extended-Euclidean + /// inverse beats a 265-operation addition chain, and callers that may only use a variable-time + /// inverse are already spelled that way at the call site. + /// + /// Scope: Ed25519 -- signature verification, where the operand is public. + // TODO -- no variable-time specialisation yet; this is the constant-time inverse. + // TODO: Claude-generated, double-check + pub const fn inv_var(&self) -> Self { + self.inv() + } + + /// [`Self::inv_var`] in place. + /// + /// Scope: Ed25519 -- see [`Self::inv_var`]. // TODO -- why are we returning the same ref we were passed? // TODO: Claude-generated, double-check pub fn inv_mut_var(&mut self) -> &mut Self { diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs.disabled similarity index 100% rename from crypto/ed/tests/x25519_tests.rs rename to crypto/ed/tests/x25519_tests.rs.disabled From cf2677bafaf53666d15e1fe2b79eb6cde574092b Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Wed, 26 Aug 2026 15:27:00 -0500 Subject: [PATCH 06/26] progress on 25519 --- crypto/ed/Cargo.toml | 5 +- ...5519_tests.rs.disabled => x25519_tests.rs} | 74 ++++++++++--------- 2 files changed, 42 insertions(+), 37 deletions(-) rename crypto/ed/tests/{x25519_tests.rs.disabled => x25519_tests.rs} (65%) diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index 419c364a2..f25a55691 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -4,8 +4,9 @@ edition.workspace = true version.workspace = true [dependencies] -bouncycastle-math.workspace = true +bouncycastle-core.workspace = true bouncycastle-utils.workspace = true [dev-dependencies] -bouncycastle-hex.workspace = true \ No newline at end of file +bouncycastle-hex.workspace = true +bouncycastle-rng.workspace = true \ No newline at end of file diff --git a/crypto/ed/tests/x25519_tests.rs.disabled b/crypto/ed/tests/x25519_tests.rs similarity index 65% rename from crypto/ed/tests/x25519_tests.rs.disabled rename to crypto/ed/tests/x25519_tests.rs index ffd96828c..220bcbb9d 100644 --- a/crypto/ed/tests/x25519_tests.rs.disabled +++ b/crypto/ed/tests/x25519_tests.rs @@ -1,37 +1,41 @@ -use bouncycastle_edcurves::x25519::*; +use bouncycastle_core; +use bouncycastle_ed::x25519::*; use bouncycastle_hex as hex; - -#[test] -fn agreement() { - precompute(); - - let mut ka = [0_u8; SCALAR_SIZE]; - let mut kb = [0_u8; SCALAR_SIZE]; - let mut qa = [0_u8; POINT_SIZE]; - let mut qb = [0_u8; POINT_SIZE]; - let mut sa = [0_u8; POINT_SIZE]; - let mut sb = [0_u8; POINT_SIZE]; - - let mut random = rand::rng(); - - for i in 1..=100 { - // Each party generates an ephemeral private key, ... - generate_private_key(&mut random, &mut ka); - generate_private_key(&mut random, &mut kb); - - // ... publishes their public key, ... - generate_public_key(&ka, &mut qa); - generate_public_key(&kb, &mut qb); - - // ... computes the shared secret, ... - let ra = calculate_agreement(&ka, &qb, &mut sa); - let rb = calculate_agreement(&kb, &qa, &mut sb); - - // ... which is the same for both parties. - assert_eq!(ra, rb, "ECDH #{}", i); - assert_eq!(sa, sb, "ECDH #{}", i); - } -} +use bouncycastle_rng; + +// todo -- debug and turn back on +// #[test] +// fn agreement() { +// // todo ??? +// precompute(); +// +// let mut ka = [0_u8; SCALAR_SIZE]; +// let mut kb = [0_u8; SCALAR_SIZE]; +// let mut qa = [0_u8; POINT_SIZE]; +// let mut qb = [0_u8; POINT_SIZE]; +// let mut sa = [0_u8; POINT_SIZE]; +// let mut sb = [0_u8; POINT_SIZE]; +// +// let mut rng = bouncycastle_rng::DefaultRNG::new(); +// +// for i in 1..=100 { +// // Each party generates an ephemeral private key, ... +// generate_private_key_rng(&mut rng, &mut ka); +// generate_private_key(&mut rng, &mut kb); +// +// // ... publishes their public key, ... +// generate_public_key(&ka, &mut qa); +// generate_public_key(&kb, &mut qb); +// +// // ... computes the shared secret, ... +// let ra = calculate_agreement(&ka, &qb, &mut sa); +// let rb = calculate_agreement(&kb, &qa, &mut sb); +// +// // ... which is the same for both parties. +// assert_eq!(ra, rb, "ECDH #{}", i); +// assert_eq!(sa, sb, "ECDH #{}", i); +// } +// } #[test] fn iterated() { @@ -115,8 +119,8 @@ fn check_value(n: &[u8], text: &str, se: &str) { } fn check_vector(sk: &str, su: &str, se: &str, text: &str) { - let k: [u8; SCALAR_SIZE] = hex::decode(sk).try_into().unwrap(); - let u: [u8; POINT_SIZE] = hex::decode(su).try_into().unwrap(); + let k: [u8; SCALAR_SIZE] = hex::decode(sk).unwrap().try_into().unwrap(); + let u: [u8; POINT_SIZE] = hex::decode(su).unwrap().try_into().unwrap(); let mut r = [0_u8; POINT_SIZE]; calculate_agreement(&k, &u, &mut r); From d2cf4f18a037ebae0213cd5db7df672577b6167b Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 08:42:49 -0500 Subject: [PATCH 07/26] Adding tests and a todo list for finishing this feature. --- Cargo.toml | 1 - crypto/ed/Cargo.toml | 1 + crypto/ed/ed_todo.md | 216 +++++++++++++++++++++++++++ crypto/ed/src/curve25519.rs | 7 +- crypto/ed/src/x25519.rs | 10 +- crypto/ed/tests/x25519_tests.rs | 35 +++++ crypto/ed/tests/x25519_wycheproof.rs | 60 ++++++++ crypto/math/Cargo.toml | 6 - crypto/math/src/lib.rs | 16 -- crypto/math/src/modular.rs | 46 ------ 10 files changed, 324 insertions(+), 74 deletions(-) create mode 100644 crypto/ed/ed_todo.md create mode 100644 crypto/ed/tests/x25519_wycheproof.rs delete mode 100644 crypto/math/Cargo.toml delete mode 100644 crypto/math/src/lib.rs delete mode 100644 crypto/math/src/modular.rs diff --git a/Cargo.toml b/Cargo.toml index ae702e142..c11a03f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ bouncycastle-factory = { path = "./crypto/factory" } bouncycastle-hex = { path = "./crypto/hex" } bouncycastle-hkdf = { path = "./crypto/hkdf" } bouncycastle-hmac = { path = "./crypto/hmac" } -bouncycastle-math = { path = "./crypto/math" } bouncycastle-mlkem = { path = "./crypto/mlkem" } bouncycastle-mlkem-lowmemory = { path = "./crypto/mlkem-lowmemory" } bouncycastle-mldsa = { path = "./crypto/mldsa" } diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index f25a55691..0b180f34d 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -8,5 +8,6 @@ bouncycastle-core.workspace = true bouncycastle-utils.workspace = true [dev-dependencies] +bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true bouncycastle-rng.workspace = true \ No newline at end of file diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md new file mode 100644 index 000000000..4e3bb13ab --- /dev/null +++ b/crypto/ed/ed_todo.md @@ -0,0 +1,216 @@ +# bouncycastle-ed TODO + +Work for the `bouncycastle-ed` crate, in priority order. Each algorithm family goes through three +phases, in order, each in stacked commits: + +1. x25519 +2. x448 +3. ed25519 / ed448 + +Within each, the priority order is: + +1. **Functional**: correct output, passing the RFC and Wycheproof vectors. +2. **Traits**: implementing the appropriate traits from `crypto/core/src/traits.rs`. +3. **Polish**: docs, API ergonomics, and the extra APIs. + +--- + +# X25519 (RFC 7748) + +## Status and Gap analysis (2026-10-28) + +Status as of 2026-10-28: X25519 passes every RFC 7748 vector and all 518 Wycheproof +`x25519_test.json` vectors, all checked in as tests (`x25519_tests.rs`, +`x25519_wycheproof.rs`). X448, Ed25519 and Ed448 are not started on this +branch; `../bc-rust-nursery/crates/{x448,ed25519,ed448,curve448}` hold earlier work to port from. +bc-test-data has no vectors for any of the four. + +The vector-suite loaders will skip silently here: the local bc-test-data checkout is named +`bc-test-data.donttest`, so `core-test-framework`'s loaders would skip it with a warning. That has +no effect today because it has no vectors for these algorithms, but bear it in mind when adding +suites. + +How each vector source fares on `feature/x25519` at `cf2677ba`. "Checked in" means a test in +`tests/` runs it. + +| Source | X25519 | Checked in? | +|---------------------------------------------|---------------------------------------|-----------------------------| +| RFC 7748 §5.2 single-call vectors (2) | pass | yes: `vector_1`, `vector_2` | +| RFC 7748 §5.2 iterated, 1 and 1,000 rounds | pass | yes: `iterated` | +| RFC 7748 §5.2 iterated, 1,000,000 rounds | pass (~39 s in release) | yes, but `#[ignore]` | +| RFC 7748 §6.1 Diffie-Hellman | pass (both public keys and K) | yes: `rfc7748_section_6_1` | +| Wycheproof `x25519_test.json` | 518/518 output bytes match | yes: `x25519_wycheproof.rs` | +| Wycheproof `x25519_{asn,jwk,pem}_test.json` | not run (needs key decoding, Phase 3) | no | +| bc-test-data | no X25519 vectors exist | n/a | + +What the analysis found: + +- **X25519 arithmetic is correct.** The Wycheproof run covered the flags `EdgeCaseMultiplication`, + `EdgeCasePrivateKey`, `EdgeCaseShared`, `Ktv`, `LowOrderPublic`, `NonCanonicalPublic`, + `Normal`, `SmallPublicKey`, `SpecialPublicKey`, `Twist` and `ZeroSharedSecret`. So the open + TODO comments about RFC §5 masking and non-canonical input describe missing tests, not missing + behaviour. +- **Low-order public keys** produce the all-zero shared secret, which matches Wycheproof's bytes. + `calculate_agreement` returns `false` for these. Wycheproof grades them "acceptable", so + whether to reject them is still an API decision (Phase 2). +- **The X25519 gaps are test coverage and API, not correctness.** The only callable surface is + free functions over `[u8; 32]` (`calculate_agreement`, `generate_public_key`). There is no + private-key generation, and the round-trip test `agreement` is commented out. +- **Build warnings:** 7 dead-code warnings in the lib and 2 unused imports in the test. + +## Phase 1: Functional + +- [x] Add a Wycheproof suite `tests/x25519_wycheproof.rs` over `x25519_test.json`: 518 vectors, + 264 valid and 254 acceptable, 31 with an all-zero shared secret. (maybe need to be renamed / refactored to match other + crates) +- [ ] Decide what happens on an all-zero shared secret (low-order public key). Today + `calculate_agreement` outputs zeros and returns `false`; Wycheproof marks these vectors + "acceptable". Reject, or pass through? This maybe requires some research to see if this condition can be detected on + the public inputs before peforming the private key operations (would be better for constan-time concerns). +- [ ] Close the TODO comments in `x25519.rs` and `curve25519.rs` about RFC §5 top-bit masking and + non-canonical input; the Wycheproof suite now covers both. +- [x] Add the RFC 7748 §6.1 Diffie-Hellman vectors: Alice's and Bob's public keys and the shared + secret K. +- [ ] Label `vector_1`/`vector_2` and the iterated test as RFC 7748 §5.2 (the comment says + "RFC7749"), and drop the "what is this?" TODO on `check_iterated`; it is the §5.2 iterated + test. Fix the stale comment on `scalar_mult` that says these vectors are not exercised. +- [ ] Re-enable the `agreement` round-trip test. It currently calls a non-existent + `generate_private_key_rng`. +- [ ] Remove the unused imports (`bouncycastle_core`, `bouncycastle_rng`) and the commented-out + `decode_hex` from `x25519_tests.rs`. +- [ ] Review `CoordField` in `curve25519.rs`. Ed25519 reuses it, so get it right here first: + - [ ] Review every method marked `// TODO: Claude-generated, double-check`, plus + `decode_scalar_25519` and `scalar_mult` in `x25519.rs`. + - [ ] Think about whether each field-arithmetic function needs a basic unit test. + - [ ] Decide how the normalization precondition is enforced. `encode_255`, `encode64_255`, + `are_equal(_var)` and `parity` need normalized input. The options, from the comment in + `curve25519.rs`, are: fold `normalize` into `encode_255`, add a `Normalized` typestate, or + add debug-only magnitude tracking. + - [x] Identify `C_A` (486662) and `C_A24` and document them against RFC 7748. +- [ ] Remove the dead code: `Scalar25519`/`UCoordinate25519` (`todo!()`), `encode_u_coordinate`, + `P32`, `P64`, and the empty `precompute()`. Or finish them, if Phase 3 wants them. +- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). + +## Phase 2: Traits, Bells & Whistles + +- [ ] Finish the `KeyAgreement` / `KeyAgreementPublicKey` / `KeyAgreementPrivateKey` traits in + `traits.rs`. Today they are empty markers. They need: + - [ ] key `from_bytes`/encoding, by analogy with `KEMPublicKey`/`KEMPrivateKey` (lengths as + const generics); + - [ ] the output type, wrapped in `KeyMaterial` (the shared secret is sensitive); + - [ ] an error type. +- [ ] Add X25519 key types: `Secret`-backed private keys, zeroized, no `Debug` leak. +- [ ] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. +- [ ] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from + X25519. +- [ ] A `KEM` impl over X25519 (DHKEM-style). Mentioned in an `x25519.rs` TODO; decide if it + belongs here. +- [ ] Keygen: + - [ ] random keygen from an `RNG`; + - [ ] deterministic keygen from a supplied 32-byte seed; + - [ ] public-from-private derivation and consistency-check. +- [ ] Fast fixed-base `scalar_mult_base`. Today it runs the generic ladder with u = 9; there is a + commented-out Edwards-form path. +- [ ] Wire into the factory (a `KeyAgreementFactory`, if that fits the factory pattern) and add a + CLI subcommand (`cli/src/*_cmd.rs`). + +## Phase 3: Docs and ergonomics + +- [ ] Settle visibility: `CoordField` and most of its methods, and `calculate_agreement`, are + `pub`; should they be `pub(crate)`? +- [ ] `CoordField` cleanups: + - [ ] `[Secret; 5]`? Block `Debug`? Prune the derive list. + - [ ] Should the `*_mut` methods return `&mut Self`, and do we need both value and `_mut` + forms? + - [ ] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. + - [ ] Rename `parity` (to `is_odd`?). + - [ ] Remove the answered "why are we cloning?" TODO. +- [ ] Docs: + - [ ] Re-enable `#![forbid(missing_docs)]`. + - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md. + - [ ] Is XDH in a NIST document? (`lib.rs` `# Standards`.) +- [ ] Benches: criterion, plus a `mem_usage_benches` harness and a `# Memory Usage` docs section. + +--- + +# X448 (RFC 7748) + +Not started on this branch; earlier work is in `bc-rust-nursery/crates/{curve448,x448}`. Mostly a +twin of X25519 over the second field: start once X25519's Phase 2 has settled the traits and key +types, and carry each X25519 item across. Only the differences are listed here. + +## Phase 1: Functional + +- [ ] Twin `curve25519.rs` / `x25519.rs` as `curve448.rs` / `x448.rs`: field element mod + p = 2^448 − 2^224 − 1, 56-byte scalars and u-coordinates, and the same ladder. +- [ ] Differences from X25519 (RFC 7748 §5): + - [ ] `decodeScalar448` clears the two low bits and sets bit 447; no high bit to clear. + - [ ] No masking of the top bit of u: that rule is X25519-only. + - [ ] a24 = (156326 − 2) / 4 = 39081. +- [ ] Vectors: RFC 7748 §5.2 (single-call and iterated), §6.2, and Wycheproof `x448_test.json` + (510 vectors; its 12 `PublicKeyTooLong` cases have no X25519 counterpart). +- [ ] Field unit tests; `cargo mutants`. + +## Phase 2: Traits, Bells & Whistles + +- [ ] Same as X25519, for X448: key types, `KeyAgreement` impls (same all-zero-secret decision), + conformance suite, KEM if X25519 has one, keygen (56-byte seed), fixed-base scalar + multiplication, factory and CLI. + +## Phase 3: Docs and ergonomics + +- [ ] Same as X25519: docs, Wycheproof, benches and + `mem_usage_benches`. + +--- + +# Ed25519 / Ed448 (RFC 8032, FIPS 186-5) + +## Phase 1: Functional + +### Ed25519 + +- [ ] Port from `bc-rust-nursery/crates/{curve25519,ed25519}` onto the shared `CoordField`: + Edwards point arithmetic, point encoding/decoding, and SHA-512 from `bouncycastle-sha2`. +- [ ] Move what X25519 shares with Ed25519 (e.g. `point_double`) into `curve25519.rs`. +- [ ] Pure Ed25519: RFC 8032 §7.1 vectors. +- [ ] Ed25519ctx: RFC 8032 §7.2 vectors. +- [ ] Ed25519ph: RFC 8032 §7.3 vectors. +- [ ] Wycheproof `ed25519_test.json`, including the malleability and non-canonical-encoding + cases. +- [ ] `sqrt_ratio_var`: check whether it branches on secret data, and constrain its use to public + inputs (decoding during verification). +- [ ] `inv_var`: real variable-time inverse for public operands (verification); today it is the + constant-time `inv`. +- [ ] `pow_p_sub5_div8`: document the addition chain's source; consider an addition-chain helper. +- [ ] Unit tests for the point arithmetic; `cargo mutants`. + +### Ed448 + +- [ ] Port from `bc-rust-nursery/crates/{curve448,ed448}`, reusing the X448 field element and + SHAKE256 from `bouncycastle-sha3`. +- [ ] Ed448: RFC 8032 §7.4 vectors. +- [ ] Ed448ph: RFC 8032 §7.5 vectors. +- [ ] Wycheproof `ed448_test.json`. +- [ ] Unit tests; `cargo mutants`. + +## Phase 2: Traits + +- [ ] Ed25519/Ed448 key types implementing `SignaturePrivateKey` / `SignaturePublicKey`. +- [ ] `Signer` / `SignatureVerifier` for pure Ed25519/Ed448, including the context string for + Ed25519ctx and Ed448. +- [ ] `PHSigner` / `PHSignatureVerifier` for Ed25519ph / Ed448ph. +- [ ] Run the `core-test-framework` `signature.rs` suite against all variants; move the RFC and + Wycheproof suites onto the trait API. +- [ ] Factory and CLI wiring, following ML-DSA's (`cli/src/mldsa_cmd.rs`). + +## Phase 3: Docs and ergonomics + +- [ ] Deterministic keygen from a 32/57-byte seed, plus random keygen from an `RNG`. +- [ ] Expose the pre-expanded private key: the hashed seed, i.e. the clamped scalar s and the + prefix. Keygen, sign-from-expanded, and serialization, so repeated signing skips the hash. +- [ ] Precomputed / cached public-key decompression for repeated verification. +- [ ] Fixed-base comb tables for keygen and signing; batch verification if wanted. +- [ ] Docs: the crate doc sections; cite FIPS 186-5 §7 alongside RFC 8032 (verify section numbers + against a downloaded copy). +- [ ] Benches: criterion, plus `mem_usage_benches`. diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index 9c6c5e393..ba9231156 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -467,9 +467,10 @@ impl CoordField { /// Verified against a reference modular inverse over 305 values, edge cases included. /// /// Scope: both x25519 and ed25519. - // TODO -- `modular::mod_odd_inverse` would be asymptotically cheaper, and P64 above is the - // constant it wants, but crypto/math is still the cargo template. Only worth revisiting - // if this shows up in a profile -- it is one call per scalar_mult. + // TODO -- a safegcd-style `mod_odd_inverse` (a commented-out draft was in the since-deleted + // crypto/math crate) would be asymptotically cheaper, and P64 above is the constant it + // wants. Only worth revisiting if this shows up in a profile -- it is one call per + // scalar_mult. // TODO: Claude-generated, double-check pub const fn inv(&self) -> Self { let (x_cubed, t) = self.pow_p_sub5_div8(); diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 3b5c809c8..665e0d512 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -8,9 +8,15 @@ use crate::curve25519::CoordField; pub const POINT_SIZE: usize = 32; pub const SCALAR_SIZE: usize = 32; -// TODO -- what is this? +/// The coefficient A of curve25519, `v^2 = u^3 + A*u^2 + u` (RFC 7748 §4.1). const C_A: u32 = 486662; -// TODO -- what is this? + +/// `(A + 2) / 4 = 121666`, the ladder's doubling constant. +/// +/// Deviation from the RFC: RFC 7748 §5 uses `a24 = (486662 - 2) / 4 = 121665` in +/// `z_2 = E * (AA + a24 * E)`. Since `E = AA - BB`, `AA + a24 * E = BB + (a24 + 1) * E`, so pairing +/// the constant with BB instead needs `a24 + 1 = (A + 2) / 4`. [`scalar_mult`] and +/// [`point_double`] both use that form. const C_A24: u32 = (C_A + 2) / 4; // TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 220bcbb9d..50e4ba288 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -70,6 +70,41 @@ fn vector_2() { ); } +/// RFC 7748 Section 6.1: Alice's and Bob's public keys, X25519(a, 9) and X25519(b, 9), and the +/// shared secret K that each derives from their own private key and the other's public key. +#[test] +fn rfc7748_section_6_1() { + let alice_sk = decode_32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"); + let bob_sk = decode_32("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"); + + let mut alice_pk = [0_u8; POINT_SIZE]; + let mut bob_pk = [0_u8; POINT_SIZE]; + generate_public_key(&alice_sk, &mut alice_pk); + generate_public_key(&bob_sk, &mut bob_pk); + check_value( + &alice_pk, + "Section 6.1 Alice's public key", + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + ); + check_value( + &bob_pk, + "Section 6.1 Bob's public key", + "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + ); + + let k = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"; + let mut alice_k = [0_u8; POINT_SIZE]; + let mut bob_k = [0_u8; POINT_SIZE]; + assert!(calculate_agreement(&alice_sk, &bob_pk, &mut alice_k)); + assert!(calculate_agreement(&bob_sk, &alice_pk, &mut bob_k)); + check_value(&alice_k, "Section 6.1 K (Alice)", k); + check_value(&bob_k, "Section 6.1 K (Bob)", k); +} + +fn decode_32(s: &str) -> [u8; 32] { + hex::decode(s).unwrap().try_into().unwrap() +} + // TODO -- what on earth is this? We're going to key_agree this with itself 1 million times? // Why? What is this testing that's not already covered by the regular KATs? fn check_iterated(count: usize) { diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs new file mode 100644 index 000000000..d68420a37 --- /dev/null +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -0,0 +1,60 @@ +//! Known-answer tests against Project Wycheproof's `testvectors_v1/x25519_test.json`. +//! +//! Requires the Wycheproof repository (https://github.com/C2SP/wycheproof) to be cloned alongside +//! this repository, i.e. at `../wycheproof` relative to the root of this git project. If it is +//! absent the tests print a warning and pass, matching the convention used by the other vector +//! suites. +//! +//! Every case is either `valid` or `acceptable`, and every case has an expected shared secret, so +//! each one is checked for exact output. The `acceptable` cases are low-order, twist and +//! non-canonical public keys, which RFC 7748 Section 5 requires to be processed rather than +//! rejected. Those whose shared secret is all zeros (flag `ZeroSharedSecret`) are also where +//! `calculate_agreement` returns `false`. + +use bouncycastle_core_test_framework::test_data_loaders::{Value, hex_field, wycheproof_json}; +use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, calculate_agreement}; + +#[test] +fn wycheproof_x25519() { + let filename = "x25519_test.json"; + let Some(doc) = wycheproof_json(filename) else { return }; + + assert_eq!(doc.get("algorithm").and_then(Value::as_str), Some("XDH"), "{filename}"); + + let (mut valid, mut acceptable, mut zero_shared) = (0usize, 0usize, 0usize); + for group in doc.get("testGroups").and_then(Value::as_array).expect("testGroups") { + assert_eq!(group.get("curve").and_then(Value::as_str), Some("curve25519"), "{filename}"); + + 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 ctx = format!("{filename} tcId {tc_id}"); + match test.get("result").and_then(Value::as_str).expect("result") { + "valid" => valid += 1, + "acceptable" => acceptable += 1, + other => panic!("{ctx}: unexpected result {other}"), + } + + let k: [u8; SCALAR_SIZE] = + hex_field(test, "private", tc_id).try_into().expect("a 32-byte private key"); + let u: [u8; POINT_SIZE] = + hex_field(test, "public", tc_id).try_into().expect("a 32-byte public key"); + let shared = hex_field(test, "shared", tc_id); + + let mut out = [0u8; POINT_SIZE]; + let nonzero = calculate_agreement(&k, &u, &mut out); + assert_eq!(out.as_slice(), shared.as_slice(), "{ctx}: shared secret"); + assert_eq!(nonzero, shared.iter().any(|&b| b != 0), "{ctx}: return value"); + zero_shared += usize::from(!nonzero); + } + } + + println!( + "Wycheproof X25519: {} cases ({valid} valid, {acceptable} acceptable; \ + {zero_shared} with an all-zero shared secret)", + valid + acceptable + ); + assert!( + valid > 0 && acceptable > 0 && zero_shared > 0, + "{filename}: expected every kind of case" + ); +} diff --git a/crypto/math/Cargo.toml b/crypto/math/Cargo.toml deleted file mode 100644 index ea1f22701..000000000 --- a/crypto/math/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "bouncycastle-math" -edition.workspace = true -version.workspace = true - -[dependencies] diff --git a/crypto/math/src/lib.rs b/crypto/math/src/lib.rs deleted file mode 100644 index b5d88072d..000000000 --- a/crypto/math/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub mod modular; - -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} diff --git a/crypto/math/src/modular.rs b/crypto/math/src/modular.rs deleted file mode 100644 index ca42536c4..000000000 --- a/crypto/math/src/modular.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! This module provides generic functionality for modular arithmetic. -// TODO -- is this right to be its own crate? Presumably over time, we'll get more generic math functions and it'll make sense to have a math crate. - -// pub fn mod_odd_inverse(m: &[u64], x: &[u64], z: &mut [u64]) -> Condition { -// let len64 = m.len(); -// debug_assert!(len64 > 0); -// debug_assert!((m[0] & 1) != 0); -// debug_assert!(m[len64 - 1] != 0); -// -// let bits = (len64 << 6) - m[len64 - 1].leading_zeros() as usize; -// let len62 = bits.div_ceil(62); -// -// // TODO Stack allocate for small sizes -// let buf = &mut vec![0_i64; len62 * 5].into_boxed_slice(); -// -// let (m62, buf) = buf.split_at_mut(len62); -// let (d62, buf) = buf.split_at_mut(len62); -// let (e62, buf) = buf.split_at_mut(len62); -// let (f62, g62) = buf.split_at_mut(len62); -// -// encode62(bits, m, m62); -// e62[0] = 1; -// f62.copy_from_slice(m62); -// encode62(bits, x, g62); -// -// // We use the "half delta" variant here, with theta == delta - 1/2 -// let mut theta = 0_i64; -// let m0_inv64 = inverse64(m62[0] as u64) as i64; -// let max_divsteps = get_maximum_hddivsteps(bits); -// let mut t; -// -// for _ in (0..max_divsteps).step_by(62) { -// (theta, t) = hddivsteps::<62>(theta, f62[0], g62[0]); -// update_de62(len62, d62, e62, &t, m0_inv64, m62); -// update_fg62(len62, f62, g62, &t); -// } -// -// let sign_f62 = Condition::::is_negative(f62[len62 - 1]); -// cnegate62(len62, sign_f62, f62); -// cnormalize62(len62, sign_f62, d62, m62); -// -// decode62(bits, d62, z); -// debug_assert!(nat::lt(m.len(), z, m).to_bool_var()); -// -// equal_to(len62, f62, 1) & equal_to(len62, g62, 0) -// } From c467db07dc0b9b54a684117d657517e1b7e4dfb0 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 21:45:51 -0500 Subject: [PATCH 08/26] Progress on x25519 --- crypto/ed/Cargo.toml | 2 +- crypto/ed/ed_todo.md | 26 +- crypto/ed/src/curve25519.rs | 1073 ++++++++++++------------------- crypto/ed/src/x25519.rs | 73 ++- crypto/ed/tests/x25519_tests.rs | 2 - 5 files changed, 460 insertions(+), 716 deletions(-) diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index 0b180f34d..d44b788b2 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -10,4 +10,4 @@ bouncycastle-utils.workspace = true [dev-dependencies] bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true -bouncycastle-rng.workspace = true \ No newline at end of file +bouncycastle-rng.workspace = true diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 4e3bb13ab..42a5d3b1f 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -78,16 +78,17 @@ What the analysis found: `generate_private_key_rng`. - [ ] Remove the unused imports (`bouncycastle_core`, `bouncycastle_rng`) and the commented-out `decode_hex` from `x25519_tests.rs`. -- [ ] Review `CoordField` in `curve25519.rs`. Ed25519 reuses it, so get it right here first: +- [ ] Review `FieldElement25519` in `curve25519.rs`. Ed25519 reuses it, so get it right here first: - [ ] Review every method marked `// TODO: Claude-generated, double-check`, plus `decode_scalar_25519` and `scalar_mult` in `x25519.rs`. - [ ] Think about whether each field-arithmetic function needs a basic unit test. - - [ ] Decide how the normalization precondition is enforced. `encode_255`, `encode64_255`, - `are_equal(_var)` and `parity` need normalized input. The options, from the comment in - `curve25519.rs`, are: fold `normalize` into `encode_255`, add a `Normalized` typestate, or - add debug-only magnitude tracking. + - [x] Enforce the normalization precondition: typestate. `normalize` returns a + `NormalizedFieldElement25519`, which alone has `encode_255`, `is_zero`/`is_one`, `parity` + and (once restored) `are_equal`. + - [ ] `normalize`'s own input bound (limbs within `reduce`'s magnitude limit) is still only + documented; debug-only magnitude tracking would check it in tests. - [x] Identify `C_A` (486662) and `C_A24` and document them against RFC 7748. -- [ ] Remove the dead code: `Scalar25519`/`UCoordinate25519` (`todo!()`), `encode_u_coordinate`, +- [ ] Remove the dead code: `Scalar25519`/`UCoordinate25519` (`todo!()`), `P32`, `P64`, and the empty `precompute()`. Or finish them, if Phase 3 wants them. - [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). @@ -116,12 +117,13 @@ What the analysis found: ## Phase 3: Docs and ergonomics -- [ ] Settle visibility: `CoordField` and most of its methods, and `calculate_agreement`, are - `pub`; should they be `pub(crate)`? -- [ ] `CoordField` cleanups: +- [x] `FieldElement25519` and its methods are `pub(crate)`. +- [ ] Make `calculate_agreement` (and the other `x25519.rs` free functions) `pub(crate)` once the + Phase 2 trait API replaces them; the tests in `tests/` call them today. +- [ ] `FieldElement25519` cleanups: - [ ] `[Secret; 5]`? Block `Debug`? Prune the derive list. - - [ ] Should the `*_mut` methods return `&mut Self`, and do we need both value and `_mut` - forms? + - [x] In-place methods (`*_mut`, `cmov`, `cnegate`) return `()`, not `&mut Self`. + - [ ] Do we need both value and `_mut` forms? Only `sqr_mut` has a caller today. - [ ] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. - [ ] Rename `parity` (to `is_odd`?). - [ ] Remove the answered "why are we cloning?" TODO. @@ -170,7 +172,7 @@ types, and carry each X25519 item across. Only the differences are listed here. ### Ed25519 -- [ ] Port from `bc-rust-nursery/crates/{curve25519,ed25519}` onto the shared `CoordField`: +- [ ] Port from `bc-rust-nursery/crates/{curve25519,ed25519}` onto the shared `FieldElement25519`: Edwards point arithmetic, point encoding/decoding, and SHA-512 from `bouncycastle-sha2`. - [ ] Move what X25519 shares with Ed25519 (e.g. `point_double`) into `curve25519.rs`. - [ ] Pure Ed25519: RFC 8032 §7.1 vectors. diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index ba9231156..bfd67a8c4 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -1,28 +1,8 @@ use bouncycastle_utils::ct::Condition; -/// The basic data value for any elliptic curve implementation is a point on the curve. -/// Holds the u-coordinate, which is an element of GF(p) with p = 2^255 - 19 for curve25519, held -/// here in a `[u8; 32]` as the representation of a point on a Montgomery curve. -/// -/// -/// # Mathematical operations implemented: -/// None. -/// All mathematical operations on a u-coordinate are implemented on [`Scalar25519`]. -/// -/// # Math Background -/// The group law on elliptic curve points is point addition where A + B yields a new point C on the -/// curve. -/// The special case of adding a point to itself, A + A is called "point doubling" and is often -/// implemented separately as this can be done more efficiently than the general case. -/// Finally, repeated addition with itself, A + A + ... + A is represented as k * A for a scalar k and -/// called "scalar multiplication", again, implemented separately. -/// -/// Curve points are represented here in Montgomery form by their u-coordinate, which defines a point -/// on the curve or on the quadratic twist up to sign; ie it names the pair {P, -P}. -/// This means that point doubling and scalar multiplication are well-defined over u-coordinates since -/// they commute with negation; ie k*(-A) = -(k*A), but general point addition is not. -/// Fortunately, XDH defined in RFC7748 only requires scalar multiplication, not general point addition. -type UCoordinate25519 = [u8; 32]; +/// The encoded u-coordinate of a point on curve25519: 32 little-endian bytes, as defined in +/// Section 5 of RFC7748. See [`FieldElement25519`] for the field it encodes an element of. +struct UCoordinate25519(Scalar25519); /// Scalar values are integers used as the k value in scalar multiplication of points. /// @@ -43,66 +23,85 @@ impl core::ops::Mul for Scalar25519 { /// Scope: X25519. // TODO -- unimplemented. `todo!()` rather than a literally empty body because `mul` has to // produce a `[u8; 32]`. - // TODO: Claude-generated, double-check fn mul(self, rhs: UCoordinate25519) -> Self::Output { todo!() } } -/// Mask for one limb: `2^51 - 1`. A [`CoordField`] holds the value +/// Mask for one limb: `2^51 - 1`. A [`FieldElement25519`] holds the value /// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204`, so `& M51` extracts a limb's own 51 bits and /// leaves the overflow to be carried into the next limb (or, out of `x4`, folded back into `x0` /// scaled by 19, since `2^255 == 19 mod p`). -/// -/// Scope: both. -// TODO: Claude-generated, double-check const M51: i64 = (1 << 51) - 1; -/// The field prime `p = 2^255 - 19` as eight little-endian 32-bit words, least significant first. +// /// The field prime `p = 2^255 - 19` as eight little-endian 32-bit words, least significant first. +// /// +// /// Currently referenced nowhere in the crate; [`FieldElement25519::normalize`] reduces via the +// /// `* 19` fold rather than by comparing against `p`. +// /// +// /// Scope: neither -- dead as of today. +// // TODO: remove? +// const P32: [u32; 8] = [ +// 0xFFFFFFED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, +// ]; +// /// The field prime `p = 2^255 - 19` as four little-endian 64-bit words, least significant first. +// /// Note the low word is `...FFED` = `-19 mod 2^64`, and the top word has bit 63 clear, giving +// /// `2^255 - 19` rather than `2^256 - 19`. +// /// +// /// Referenced only by the commented-out `modular::mod_odd_inverse` form of `inv`, so it is dead +// /// until inversion is restored. +// /// +// /// Scope: both -- once inversion lands. +// // TODO: remove? +// const P64: [u64; 4] = +// [0xFFFFFFFFFFFFFFED, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7FFFFFFFFFFFFFFF]; + +/// An element of GF(p), p = 2^255 - 19: the field that curve25519's point coordinates live in. /// -/// Currently referenced nowhere in the crate; [`CoordField::normalize`] reduces via the `* 19` -/// fold rather than by comparing against `p`. +/// This is the base field, as opposed to the scalar field of integers mod the group order that +/// private keys live in. Every coordinate in this crate is one of these: the u-coordinate of +/// X25519 (RFC7748), and the x and y coordinates of Ed25519 (RFC8032). /// -/// Scope: neither -- dead as of today. -// TODO: Claude-generated, double-check -const P32: [u32; 8] = [ - 0xFFFFFFED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, -]; -/// The field prime `p = 2^255 - 19` as four little-endian 64-bit words, least significant first. -/// Note the low word is `...FFED` = `-19 mod 2^64`, and the top word has bit 63 clear, giving -/// `2^255 - 19` rather than `2^256 - 19`. +/// # Representation +/// Five signed 64-bit limbs in radix 2^51, holding the value +/// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204` (see [`M51`]). Arithmetic is lazily reduced: +/// limbs may exceed 51 bits or go negative between operations, so the same residue mod p has many +/// representations. [`Self::normalize`] produces the canonical representation as a +/// [`NormalizedFieldElement25519`], the only type that encodes, compares and tests parity. /// -/// Referenced only by the commented-out `modular::mod_odd_inverse` form of `inv`, so it is dead -/// until inversion is restored. +/// # Mathematical operations implemented +/// The field operations: addition, subtraction and negation, multiplication and squaring, +/// inversion, and the square root of a ratio. Constant-time conditional move, negate, select and +/// swap. Encoding to and decoding from 32 bytes. Everything is constant time except the methods +/// suffixed `_var`, which are for public inputs only. /// -/// Scope: both -- once inversion lands. -// TODO: Claude-generated, double-check -const P64: [u64; 4] = - [0xFFFFFFFFFFFFFFED, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0x7FFFFFFFFFFFFFFF]; - -// TODO Add magnitude, normalized fields to CoordField in debug mode and track validity through all operations -#[derive(Clone, Copy, Debug, Default)] -#[must_use] -#[repr(transparent)] -// TODO -- are all these annotations really necessary? Doesn't this contain secrets? Don't we want to block Debug? -// TODO -- should this be `[Secret; 5]` ? -// TODO -- why is this pub and not pub(crate)? -- same question for the fn's in the impl. -pub struct CoordField([i64; 5]); - -// TODO -- all of these need at least simple unit tests -impl CoordField { - /// Add another [`CoordField`] to this one, component-wise. +/// # Math Background +/// The group law on elliptic curve points is point addition, where A + B yields a new point C on +/// the curve. The special case of adding a point to itself, A + A, is called "point doubling" and +/// is often implemented separately because it can be done more efficiently than the general case. +/// Repeated addition, A + A + ... + A, is written k * A for a scalar k and called "scalar +/// multiplication", again implemented separately. All three are built out of the field operations +/// on this type. +/// +/// X25519 represents a point in Montgomery form by its u-coordinate alone, which identifies a +/// point on the curve or its quadratic twist up to sign; ie it names the pair {P, -P}. Point +/// doubling and scalar multiplication are well-defined on u-coordinates, since they commute with +/// negation (k*(-A) = -(k*A)), but general point addition is not. XDH (RFC7748) only needs scalar +/// multiplication, which the Montgomery ladder in [`crate::x25519`] computes. Ed25519 uses the full +/// (x, y) coordinates on the equivalent twisted Edwards curve, where general addition is defined. +#[derive(Clone, Default)] +#[must_use] // to help catch bugs that x.sqr() actually returns a result rather than mutates x, so not using the return is a bug. +pub(crate) struct FieldElement25519([i64; 5]); + +impl FieldElement25519 { + /// Add another [`FieldElement25519`] to this one, component-wise. /// /// Lazily reduced: no carry propagation, so limb magnitudes grow by up to one bit and the result /// is congruent mod p but not normalized. That is deliberate -- every consumer here funnels into /// a [`Self::mul`], [`Self::sqr`] or [`Self::mul_u32`], each of which reduces through /// [`Self::reduce_products`], so magnitudes never run away and an explicit carry pass is not /// needed. - /// - /// Scope: both. In X25519 it is the `AA + a24 * E` of the Section 5 ladder (`x25519.rs:110`, - /// `148`). - // TODO: Claude-generated, double-check - pub const fn add(&self, rhs: &Self) -> Self { + pub(crate) const fn add(&self, rhs: &Self) -> Self { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; Self([x0 + y0, x1 + y1, x2 + y2, x3 + y3, x4 + y4]) @@ -119,15 +118,8 @@ impl CoordField { /// `C = x_3 + z_3` / `D = x_3 - z_3`. It is the workhorse of the ladder in [`crate::x25519`]. /// /// Note on the name: RFC7748 does not name this operation -- it writes the two assignments out - /// separately, so there is no spec name to match. This was previously called `apm`, an - /// abbreviation that appears nowhere in RFC7748 or RFC8032. It most likely came from the - /// BouncyCastle Java implementation (`X25519Field.apm`, whose `zp` / `zm` out-parameters - /// suggest "add plus minus"), but that expansion is inferred from behaviour and has not been - /// confirmed against the Java source. - /// - /// Scope: both x25519 and ed25519 - // TODO: Claude-generated, double-check - pub const fn sum_and_difference(&self, rhs: &Self) -> (Self, Self) { + /// separately, so there is no spec name to match. + pub(crate) const fn sum_and_difference(&self, rhs: &Self) -> (Self, Self) { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; ( @@ -136,157 +128,109 @@ impl CoordField { ) } - /// Add one to self, i.e. add 1 to limb `x0`, returning a new [`CoordField`]. - /// - /// One is representable exactly in the low limb, so no carry or fold is involved; as with - /// [`Self::add`] the result is congruent mod p but not normalized. - /// - /// Scope: Ed25519 -- unused today. The birational map of Section 4.1 of RFC7748, - /// `y = (u - 1)/(u + 1)`, is what wants `add_one` and [`Self::sub_one`]; the X25519 ladder never - /// adds a constant. - // TODO -- why are we cloning? [there is no clone: `self.0` is `[i64; 5]`, which is `Copy`, so the - // destructuring bind is a register move, not an allocation.] - // TODO: Claude-generated, double-check - pub const fn add_one(&self) -> Self { - let [x0, x1, x2, x3, x4] = self.0; - Self([x0 + 1, x1, x2, x3, x4]) - } - - /// [`Self::add_one`] in place. - /// - /// Scope: Ed25519 -- unused today; see [`Self::add_one`]. - // TODO -- why are we returning the same ref we were passed? - // TODO: Claude-generated, double-check - pub fn add_one_mut(&mut self) -> &mut Self { - self.0[0] += 1; - self - } - - /// Constant-time equality: `is_zero` over the OR of the limb-wise XORs. - /// - /// Requires both inputs normalized -- this compares representations, not residue classes, and a - /// value and that value plus p have different limbs. See the precondition discussion above - /// [`Self::encode_255`]. - /// - /// Scope: Ed25519 -- unused today. X25519 never compares field elements; the ladder's only - /// output check is the all-zeros test on the encoded bytes (`x25519.rs:29`). - // TODO: Claude-generated, double-check - pub const fn are_equal(lhs: &Self, rhs: &Self) -> Condition { - let [x0, x1, x2, x3, x4] = lhs.0; - let [y0, y1, y2, y3, y4] = rhs.0; - Condition::::is_zero((x0 ^ y0) | (x1 ^ y1) | (x2 ^ y2) | (x3 ^ y3) | (x4 ^ y4)) - } - - /// Variable-time [`Self::are_equal`], resolving to a `bool` and therefore branchable by the - /// caller. Only for values that are not secret. - /// - /// Requires both inputs normalized; see [`Self::are_equal`]. - /// - /// Scope: Ed25519 -- unused today; signature verification compares public values. - // TODO: Claude-generated, double-check - pub const fn are_equal_var(lhs: &Self, rhs: &Self) -> bool { - Self::are_equal(lhs, rhs).to_bool() - } - - /// One weak-reduction pass: move each limb's overflow into the next, and fold the overflow out of - /// `x4` into `x0` scaled by 19 (`2^255 == 19 mod p`). - /// - /// Shifts are arithmetic, so a negative limb borrows from its neighbour correctly, and `& M51` - /// leaves every limb non-negative. The result is congruent mod p and has limbs near 51 bits, but - /// is not normalized -- `z0` in particular can exceed `2^51` after the `+ c5 * 19`. - /// - /// Verified congruent to its input mod p over 20,000 random values. - /// - /// Scope: both -- but currently unused by either, and structurally so: every ladder step passes - /// through a `mul`, `sqr` or `mul_u32`, all of which reduce via [`Self::reduce_products`], so - /// limb magnitudes never grow far enough to need a standalone carry. - // TODO: Claude-generated, double-check - pub const fn carry(&self) -> Self { - let [x0, x1, x2, x3, x4] = self.0; - let c1 = x0 >> 51; - let c2 = x1 >> 51; - let c3 = x2 >> 51; - let c4 = x3 >> 51; - let c5 = x4 >> 51; - let z0 = (x0 & M51) + c5 * 19; - let z1 = (x1 & M51) + c1; - let z2 = (x2 & M51) + c2; - let z3 = (x3 & M51) + c3; - let z4 = (x4 & M51) + c4; - Self([z0, z1, z2, z3, z4]) - } - - /// [`Self::carry`] in place. - /// - /// Scope: both -- unused today; see [`Self::carry`]. - // TODO -- why are we returning the same ref we were passed? - // TODO: Claude-generated, double-check - pub fn carry_mut(&mut self) -> &mut Self { - *self = self.carry(); - self - } - - /// Move the coordinates of x into self if `cond` is true. - /// - /// Constant time: every limb is rewritten unconditionally with a masked blend, so the branch is - /// not observable. See `bouncycastle_utils::ct::Condition`. - /// - /// Scope: Ed25519 -- unused today. Wanted for the table lookup in a fixed-base comb; X25519's - /// only conditional is the ladder swap, [`Self::cswap`]. - // TODO: Claude-generated, double-check - pub fn cmov(&mut self, cond: Condition, x: &Self) -> &mut Self { - // *self = Self::cselect(cond, x, self); - cond.mov(x.0[0], &mut self.0[0]); - cond.mov(x.0[1], &mut self.0[1]); - cond.mov(x.0[2], &mut self.0[2]); - cond.mov(x.0[3], &mut self.0[3]); - cond.mov(x.0[4], &mut self.0[4]); - self - } - - /// Negate the components of self if `cond` is true. - /// - /// Constant time, as [`Self::cmov`]. - /// - /// Scope: Ed25519 -- unused today. Wanted for signed-digit fixed-base multiplication, where a - /// table entry is conditionally negated instead of storing both signs. - // TODO: Claude-generated, double-check - pub fn cnegate(&mut self, cond: Condition) -> &mut Self { - self.0[0] = cond.negate(self.0[0]); - self.0[1] = cond.negate(self.0[1]); - self.0[2] = cond.negate(self.0[2]); - self.0[3] = cond.negate(self.0[3]); - self.0[4] = cond.negate(self.0[4]); - self - } - - /// Returns a copy of lhs if `cond` is true, else a copy of rhs. - /// - /// Constant time, as [`Self::cmov`]; the non-mutating form of it. - /// - /// Scope: Ed25519 -- unused today; see [`Self::cmov`]. - // TODO: Claude-generated, double-check - pub fn cselect(cond: Condition, lhs: &Self, rhs: &Self) -> Self { - let [x0, x1, x2, x3, x4] = lhs.0; - let [y0, y1, y2, y3, y4] = rhs.0; - let z0 = cond.select(x0, y0); - let z1 = cond.select(x1, y1); - let z2 = cond.select(x2, y2); - let z3 = cond.select(x3, y3); - let z4 = cond.select(x4, y4); - Self([z0, z1, z2, z3, z4]) - } + // /// Add one to self, i.e. add 1 to limb `x0`, returning a new [`FieldElement25519`]. + // /// + // /// One is representable exactly in the low limb, so no carry or fold is involved; as with + // /// [`Self::add`] the result is congruent mod p but not normalized. + // /// + // /// Scope: Ed25519 -- unused today. The birational map of Section 4.1 of RFC7748, + // /// `y = (u - 1)/(u + 1)`, is what wants `add_one` and [`Self::sub_one`]; the X25519 ladder never + // /// adds a constant. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn add_one(&self) -> Self { + // let [x0, x1, x2, x3, x4] = self.0; + // Self([x0 + 1, x1, x2, x3, x4]) + // } + + // /// [`Self::add_one`] in place. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) fn add_one_mut(&mut self) { + // self.0[0] += 1; + // } + + // /// Carry each limb's overflow above 51 bits into the next limb up. + // /// The overflow out of the top limb `x4` has weight `2^255`, so it wraps around + // /// into `x0` multiplied by 19, since `2^255 == 19 mod p`. + // /// + // /// This is a weak reduction: the value is unchanged mod p, but the result is not normalized -- it + // /// may be `>= p`. Use [`Self::normalize`] for the canonical representation. + // /// + // /// All five carries are taken from the input limbs at once, rather than rippling up from `x0` as + // /// in [`Self::reduce`]. That keeps the five steps independent, so the longest dependency chain is + // /// about three instructions (shift, `* 19`, add) rather than a ripple's ten or so, which an + // /// out-of-order CPU can overlap; it also lets LLVM auto-vectorize limbs 1..4. The cost is that + // /// each output limb is only bounded by `2^51` plus the carry it received: for input limbs below + // /// `2^63` in magnitude a carry lies in `[-2^12, 2^12)`, so `z1..z4` lie in + // /// `[-2^12, 2^51 + 2^12)` and `z0` in `[-19 * 2^12, 2^51 + 19 * 2^12)`. + // /// + // /// `>> 51` is an arithmetic shift, i.e. floor division by `2^51`, and `& M51` the matching + // /// non-negative remainder, so a negative limb borrows from its neighbour correctly. A borrow can + // /// leave the next limb negative, so the output limbs are not guaranteed non-negative. + // pub(crate) const fn carry(&self) -> Self { + // let [x0, x1, x2, x3, x4] = self.0; + // let c1 = x0 >> 51; + // let c2 = x1 >> 51; + // let c3 = x2 >> 51; + // let c4 = x3 >> 51; + // let c5 = x4 >> 51; + // let z0 = (x0 & M51) + c5 * 19; + // let z1 = (x1 & M51) + c1; + // let z2 = (x2 & M51) + c2; + // let z3 = (x3 & M51) + c3; + // let z4 = (x4 & M51) + c4; + // Self([z0, z1, z2, z3, z4]) + // } + + // /// Conditionally move the coordinates of x into self if `cond` is true. + // /// + // /// Constant-time. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) fn cmov(&mut self, cond: Condition, x: &Self) { + // // *self = Self::cselect(cond, x, self); + // cond.mov(x.0[0], &mut self.0[0]); + // cond.mov(x.0[1], &mut self.0[1]); + // cond.mov(x.0[2], &mut self.0[2]); + // cond.mov(x.0[3], &mut self.0[3]); + // cond.mov(x.0[4], &mut self.0[4]); + // } + + // /// Returns a copy of lhs if `cond` is true, else a copy of rhs. + // /// + // /// Constant time. + // /// + // /// Scope: Ed25519 -- unused today; see [`Self::cmov`]. + // // TODO -- add back once we get to implementing Ed25519 + // // TODO -- do we really need this? Seems like this is equivalent to lhs.cmov(cond, rhs); Do we really need both? + // pub(crate) fn cselect(cond: Condition, lhs: &Self, rhs: &Self) -> Self { + // let [x0, x1, x2, x3, x4] = lhs.0; + // let [y0, y1, y2, y3, y4] = rhs.0; + // let z0 = cond.select(x0, y0); + // let z1 = cond.select(x1, y1); + // let z2 = cond.select(x2, y2); + // let z3 = cond.select(x3, y3); + // let z4 = cond.select(x4, y4); + // Self([z0, z1, z2, z3, z4]) + // } + + // /// Negate the components of self if `cond` is true. + // /// + // /// Constant time. + // // TODO -- add back once we get to implementing Ed25519 + // // TODO -- seems weird to negate the limbs instead of negating only the leading digit + // pub(crate) fn cnegate(&mut self, cond: Condition) { + // self.0[0] = cond.negate(self.0[0]); + // self.0[1] = cond.negate(self.0[1]); + // self.0[2] = cond.negate(self.0[2]); + // self.0[3] = cond.negate(self.0[3]); + // self.0[4] = cond.negate(self.0[4]); + // } /// Conditionally Swap the values of lhs and rhs if `cond` is true. /// /// Constant time: both limb arrays are rewritten unconditionally with a masked exchange, so /// nothing about `cond` -- and hence nothing about the scalar bit driving it -- leaks through /// memory access patterns or timing. - /// - /// Scope: X25519. This is `cswap(swap, x_2, x_3)` from the Section 5 ladder of RFC7748, the - /// mechanism that makes the ladder scalar-bit oblivious. - // TODO: Claude-generated, double-check - pub fn cswap(cond: Condition, lhs: &mut Self, rhs: &mut Self) { + pub(crate) fn cswap(cond: Condition, lhs: &mut Self, rhs: &mut Self) { (lhs.0[0], rhs.0[0]) = cond.swap(lhs.0[0], rhs.0[0]); (lhs.0[1], rhs.0[1]) = cond.swap(lhs.0[1], rhs.0[1]); (lhs.0[2], rhs.0[2]) = cond.swap(lhs.0[2], rhs.0[2]); @@ -294,160 +238,47 @@ impl CoordField { (lhs.0[4], rhs.0[4]) = cond.swap(lhs.0[4], rhs.0[4]); } - // TODO -- decide how the normalization precondition is handled across this codec group. - // This comment was generated by Claude and left here to be considered unce I have - // a better understanding of the code. - // - // `decode_u_coordinate` deliberately returns an un-normalized value: RFC 7748 Section 5 requires that - // non-canonical u-coordinates (u >= p) be accepted rather than rejected. But `encode_255`, - // `encode64_255`, `are_equal`, `are_equal_var` and `parity` all *require* a normalized input, - // enforced today only by a documented precondition that every call site has to remember. All of - // them currently do (`scalar_mult` and `encode_u_coordinate` in x25519.rs, `inv` below), so this - // is a latent footgun rather than a live bug -- but the asymmetry is ugly: decoding asks nothing - // of the caller, encoding and comparison ask for a step they can silently omit. `parity` is the - // sharpest edge: p is odd, so a value in [p, 2^255) reports the parity of v rather than v - p. - // - // Options: - // 1. Fold `self.normalize()` into `encode_255` and drop it from its call sites. Free -- since - // normalize is idempotent this moves work rather than adding it -- and encode is outside the - // ladder loop, so nothing hot is touched. Does not help `are_equal`/`parity`, and folding it - // into `are_equal` WOULD cost, because that one can land in hot code. - // 2. Typestate: `normalize(self) -> Normalized`, with `encode_255`, `are_equal` and `parity` - // defined only on `Normalized`. Compile-time enforcement, which is what - // QUALITY_AND_STYLE.md asks for over runtime discipline, and it covers the whole group at - // once. Bigger change, and it wants the full list of consumers -- Ed25519 point compression - // will add more -- so it probably waits for that (see the deferral note atop x25519.rs). - // 3. Debug-only tracking, i.e. the magnitude/normalized fields already proposed in the TODO at - // the top of this file. Catches violations in tests without changing the API, but proves - // nothing in release and puts a debug-only field on a `Copy` type. - // - // None of these remove the precondition outright: `normalize` itself needs limbs inside the - // magnitude bound that `reduce`'s carry chain assumes (`t5 * 19 + x0` must not overflow i64). - // That residual bound is exactly what option 3 would track. - - /// Encodes a field element as a 255-bit value in 32 little-endian bytes; the inverse of [`Self::decode_u_coordinate`]. - /// Bit 255 (the most significant bit of the last byte) is always written as zero. - /// - /// Callers that need that bit to carry something else -- the - /// sign of x in an Ed25519 point encoding (Section 5.1.3 of RFC 8032) -- must OR it in after. - /// - /// Note: Requires normalized self -- see [`Self::encode64_255`]; only a normalized value encodes to the - /// canonical byte string for its residue class. - /// - /// Input: self, normalized; a 32-byte output buffer. - /// Output: The value written to `bytes` as a 255-bit little-endian integer. - /// - /// Scope: both. In X25519 it is `encodeUCoordinate` (`x25519.rs:85`, `174`). - // TODO: Claude-generated, double-check - pub fn encode_255(&self, bytes: &mut [u8; 32]) { - let [w0, w1, w2, w3] = self.encode64_255(); - let (a, b, c, d) = (w0.to_le_bytes(), w1.to_le_bytes(), w2.to_le_bytes(), w3.to_le_bytes()); - // Each word fills a fixed 8-byte window, least significant word first. Every index is a - // constant into a fixed-size array, so an out-of-bounds index would be a compile error (the - // deny-by-default `unconditional_panic` lint) and no runtime bounds check survives. - *bytes = [ - a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], // - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], // - c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], // - d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], - ]; - } - - /// Packs the five 51-bit limbs into a 255-bit little-endian integer held as four 64-bit words, - /// each word taking a whole limb plus the low bits of the next. Inverse of - /// [`Self::decode64_255`]. Bit 255 of the result is always zero, since limb `x4` lands in bits - /// 204..254. - /// - /// Note: Requires normalized self: every limb must be in [0, 2^51) and the value reduced mod p. - /// The splices below are `|`, not `+`, so a limb with bits at or above 51 collides with the next - /// limb's bits instead of carrying into them, and the result is garbage. (The `x1 << 51` etc. - /// deliberately shift bits off the top of the i64 -- those are the bits the previous word - /// already carried.) - /// - /// Input: self, normalized. - /// Output: The value as four little-endian 64-bit words, least significant word first. - /// - /// Scope: both -- the packing half of [`Self::encode_255`]. - // TODO: Claude-generated, double-check - const fn encode64_255(&self) -> [u64; 4] { - let [x0, x1, x2, x3, x4] = self.0; - let z0 = (x0 | x1 << 51) as u64; - let z1 = (x1 >> 13 | x2 << 38) as u64; - let z2 = (x2 >> 26 | x3 << 25) as u64; - let z3 = (x3 >> 39 | x4 << 12) as u64; - [z0, z1, z2, z3] - } + /// Decodes 32 little-endian bytes as a field element, ignoring bit 255 (the most significant + /// bit of the final byte); the inverse of [`NormalizedFieldElement25519::encode_255`]. + /// + /// This is the RFC-neutral byte layout shared by both protocols. Callers own the meaning of + /// bit 255 and the policy on non-canonical input: X25519 ignores the bit and accepts values + /// `>= p` (`decode_u_coordinate` in `x25519.rs`, RFC7748 Section 5), while Ed25519 reads the bit + /// as the sign of x and rejects a y-coordinate `>= p` (RFC8032 Section 5.1.3). + /// + /// Note: Result is NOT normalized -- the value is in `[0, 2^255)`, so the 19 encodings of + /// `2^255 - 19 .. 2^255 - 1` decode to values `>= p`. Call [`Self::normalize`] before comparing + /// or encoding. + /// + /// Input: 32 bytes, read as a little-endian integer. + /// Output: The low 255 bits of that integer as a [`FieldElement25519`]. + pub(crate) const fn decode_255(bytes: &[u8; 32]) -> Self { + /// Unpacks a 256-bit little-endian integer held as four 64-bit words into the five 51-bit limbs + /// of a [`FieldElement25519`], where the represented value is + /// `z0 + z1*2^51 + z2*2^102 + z3*2^153 + z4*2^204`. Each limb is a 51-bit window of the input, + /// straddling word boundaries; the `& M51` masks make every limb non-negative and leave headroom + /// for lazily-carried arithmetic. Bit 255 of the input is dropped -- see [`Self::decode_255`]. + /// Inverse of [`NormalizedFieldElement25519::encode64_255`]. + /// Input: A 256-bit integer as four little-endian 64-bit words, least significant word first. + /// Output: Bits 0..254 of that integer as a [`FieldElement25519`]. + /// + /// Note: Result is NOT normalized. + const fn decode64_255(x: &[u64; 4]) -> FieldElement25519 { + let [x0, x1, x2, x3] = *x; + let z0 = x0 as i64 & M51; + let z1 = (x0 >> 51 | x1 << 13) as i64 & M51; + let z2 = (x1 >> 38 | x2 << 26) as i64 & M51; + let z3 = (x2 >> 25 | x3 << 39) as i64 & M51; + let z4 = (x3 >> 12) as i64 & M51; + FieldElement25519([z0, z1, z2, z3, z4]) + } - /// `decodeUCoordinate(u, 255)` as defined in Section 5 of RFC7748: - /// - /// ```text - /// def decodeUCoordinate(u, bits): - /// u_list = [ord(b) for b in u] - /// # Ignore any unused bits. - /// if bits % 8: - /// u_list[-1] &= (1<<(bits%8))-1 - /// return decodeLittleEndian(u_list, bits) - /// ``` - /// - /// The RFC's `bits` parameter is fixed at 255 by this type, so `bits % 8` is 7 and the mask - /// clears bit 255 -- the most significant bit of the final byte. Section 5 requires exactly - /// that: "When receiving such an array, implementations of X25519 (but not X448) MUST mask the - /// most significant bit in the final byte." - /// - /// Callers own the meaning of that bit: for X25519 it is unused, for Ed25519 it carries the - /// sign of x (Section 5.1.3 of RFC8032) and must be stripped off and handled separately. - /// - /// The RFC's `decodeLittleEndian` has no counterpart of its own -- it is folded into this - /// function and [`Self::decode64_255`]. - /// - /// Verified equal to `decodeUCoordinate(b, 255)` over 20,004 inputs, random plus edge cases. - /// - /// Note: Result is NOT normalized -- the value is in [0, 2^255), so the 19 encodings of - /// 2^255-19 .. 2^255-1 decode to values >= p. That is also required by Section 5: - /// "Implementations MUST accept non-canonical values and process them as if they had been - /// reduced modulo the field prime. The non-canonical values are 2^255 - 19 through 2^255 - 1 - /// for X25519". Call [`Self::normalize`] before comparing or encoding. - /// - /// Note on the name: Ed25519 needs this same byte-to-field decode for a *y*-coordinate, where - /// the masked bit is the sign of x rather than unused, so at that call site the RFC7748 name - /// will read wrong. It follows RFC7748 because that is the only caller today; revisit when - /// Ed25519 lands. - /// - /// Input: A 32-byte little-endian u-coordinate. - /// Output: The low 255 bits of that value as a [`CoordField`]. - /// - /// Scope: both x25519 and ed25519 - // TODO: Claude-generated, double-check - pub const fn decode_u_coordinate(bytes: &[u8; 32]) -> Self { let b = bytes; let x0 = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); let x1 = u64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]); let x2 = u64::from_le_bytes([b[16], b[17], b[18], b[19], b[20], b[21], b[22], b[23]]); let x3 = u64::from_le_bytes([b[24], b[25], b[26], b[27], b[28], b[29], b[30], b[31]]); - Self::decode64_255(&[x0, x1, x2, x3]) - } - - /// Repacks a 256-bit little-endian integer held as four 64-bit words into the five 51-bit limbs - /// of a [`CoordField`], where the represented value is - /// `z0 + z1*2^51 + z2*2^102 + z3*2^153 + z4*2^204`. Each limb is a 51-bit window of the input, - /// straddling word boundaries; the `& M51` masks make every limb non-negative and leave headroom - /// for lazily-carried arithmetic. Bit 255 of the input is dropped -- see [`Self::decode_u_coordinate`]. - /// Inverse of [`Self::encode64_255`]. - /// Input: A 256-bit integer as four little-endian 64-bit words, least significant word first. - /// Output: Bits 0..254 of that integer as a [`CoordField`]. - /// - /// Note: Result is NOT normalized -- see the note on [`Self::decode_u_coordinate`]. - /// - /// Scope: both -- the unpacking half of [`Self::decode_u_coordinate`]. - // TODO: Claude-generated, double-check - const fn decode64_255(x: &[u64; 4]) -> Self { - let [x0, x1, x2, x3] = *x; - let z0 = x0 as i64 & M51; - let z1 = (x0 >> 51 | x1 << 13) as i64 & M51; - let z2 = (x1 >> 38 | x2 << 26) as i64 & M51; - let z3 = (x2 >> 25 | x3 << 39) as i64 & M51; - let z4 = (x3 >> 12) as i64 & M51; - Self([z0, z1, z2, z3, z4]) + decode64_255(&[x0, x1, x2, x3]) } /// Multiplicative inverse in GF(p), by Fermat's little theorem: `x^(p-2) == x^-1`. @@ -467,96 +298,14 @@ impl CoordField { /// Verified against a reference modular inverse over 305 values, edge cases included. /// /// Scope: both x25519 and ed25519. - // TODO -- a safegcd-style `mod_odd_inverse` (a commented-out draft was in the since-deleted - // crypto/math crate) would be asymptotically cheaper, and P64 above is the constant it - // wants. Only worth revisiting if this shows up in a profile -- it is one call per - // scalar_mult. - // TODO: Claude-generated, double-check - pub const fn inv(&self) -> Self { + // TODO -- a safegcd-style `mod_odd_inverse` would be asymptotically cheaper, and P64 above is + // the constant it wants. Only worth revisiting if this shows up in a profile -- it is + // one call per scalar_mult. + pub(crate) const fn inv(&self) -> Self { let (x_cubed, t) = self.pow_p_sub5_div8(); t.sqr_n(3).mul(&x_cubed) } - /// [`Self::inv`] in place. - /// - /// Scope: both x25519 and ed25519. - // TODO -- why are we returning the same ref we were passed? - // TODO: Claude-generated, double-check - pub fn inv_mut(&mut self) -> &mut Self { - *self = self.inv(); - self - } - - /// Variable-time multiplicative inverse. - /// - /// Currently just [`Self::inv`], which is constant time -- so this is correct and safe, merely - /// not faster. The separate name reserves the slot: for a public operand, an extended-Euclidean - /// inverse beats a 265-operation addition chain, and callers that may only use a variable-time - /// inverse are already spelled that way at the call site. - /// - /// Scope: Ed25519 -- signature verification, where the operand is public. - // TODO -- no variable-time specialisation yet; this is the constant-time inverse. - // TODO: Claude-generated, double-check - pub const fn inv_var(&self) -> Self { - self.inv() - } - - /// [`Self::inv_var`] in place. - /// - /// Scope: Ed25519 -- see [`Self::inv_var`]. - // TODO -- why are we returning the same ref we were passed? - // TODO: Claude-generated, double-check - pub fn inv_mut_var(&mut self) -> &mut Self { - *self = self.inv_var(); - self - } - - /// Constant-time test for the value 1: `is_zero` over `(x0 ^ 1) | x1 | x2 | x3 | x4`. - /// - /// Requires normalized self -- only the canonical representative of 1 has limbs `[1, 0, 0, 0, 0]`. - /// - /// Scope: Ed25519 -- unused today. - // TODO: Claude-generated, double-check - pub const fn is_one(&self) -> Condition { - let [x0, x1, x2, x3, x4] = self.0; - let x0 = x0 ^ 1; - Condition::::is_zero(x0 | x1 | x2 | x3 | x4) - } - - /// Variable-time [`Self::is_one`]. Only for values that are not secret. - /// - /// Requires normalized self. - /// - /// Scope: Ed25519 -- unused today. - // TODO: Claude-generated, double-check - pub const fn is_one_var(&self) -> bool { - self.is_one().to_bool() - } - - /// Constant-time test for the value 0: `is_zero` over the OR of all five limbs. - /// - /// Requires normalized self -- an un-normalized zero can be represented as p, whose limbs are - /// not all zero. - /// - /// Scope: Ed25519. Reached today only through [`Self::is_zero_var`] from - /// [`Self::sqrt_ratio_var`]. - // TODO: Claude-generated, double-check - pub const fn is_zero(&self) -> Condition { - let [x0, x1, x2, x3, x4] = self.0; - Condition::::is_zero(x0 | x1 | x2 | x3 | x4) - } - - /// Variable-time [`Self::is_zero`]. Only for values that are not secret. - /// - /// Requires normalized self. - /// - /// Scope: Ed25519. Used by [`Self::sqrt_ratio_var`] to pick between the three square-root cases; - /// the branch is safe there because point decompression runs on public data. - // TODO: Claude-generated, double-check - pub const fn is_zero_var(&self) -> bool { - self.is_zero().to_bool() - } - /// Multiply self by rhs in GF(p), `p = 2^255 - 19`. /// /// Schoolbook 5x5: output limb `k` collects every `x[i] * y[j]` with `i + j == k`, plus every @@ -564,12 +313,7 @@ impl CoordField { /// `2^255 == 19 mod p`, so those terms are pre-scaled by 19 -- that is what `t1..t4` are. The 25 /// products accumulate in `i128` (a 51-bit by 56-bit product would overflow `i64`) and /// [`Self::reduce_products`] carries them back down to five `i64` limbs. - /// - /// Verified against reference mod-p arithmetic over 20,000 random pairs. - /// - /// Scope: both. - // TODO: Claude-generated, double-check - pub const fn mul(&self, rhs: &Self) -> Self { + pub(crate) const fn mul(&self, rhs: &Self) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { x as i128 * y as i128 @@ -595,9 +339,8 @@ impl CoordField { /// whatever carries out of the top limb inside [`Self::reduce_products`]. /// /// Scope: X25519. This is the `a24 * E` of the Section 5 ladder, with - /// `a24 = (486662 - 2) / 4 = 121665` (`x25519.rs:110`, `148`). - // TODO: Claude-generated, double-check - pub const fn mul_u32(&self, rhs: u32) -> Self { + /// `a24 = (486662 - 2) / 4 = 121665`. + pub(crate) const fn mul_u32(&self, rhs: u32) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { x as i128 * y as i128 @@ -609,102 +352,51 @@ impl CoordField { Self::reduce_products(m(x0, y0), m(x1, y0), m(x2, y0), m(x3, y0), m(x4, y0)) } - /// Negates self, component-wise. - /// - /// Negating every limb negates the value, since the limb weights `2^(51*i)` are all positive. - /// Lazily reduced, and note the result has negative limbs, which is legal in this representation - /// but means it is *not* normalized -- [`Self::normalize`] must run before any encode or - /// comparison. - /// - /// Scope: Ed25519 -- unused today. Wanted for point negation and for the conditional negation of - /// signed-digit fixed-base tables; the X25519 ladder never negates. - // TODO: Claude-generated, double-check - pub const fn negate(&self) -> Self { - let [x0, x1, x2, x3, x4] = self.0; - Self([-x0, -x1, -x2, -x3, -x4]) - } - - /// [`Self::negate`] in place. - /// - /// Scope: Ed25519 -- unused today; see [`Self::negate`]. - // TODO: Claude-generated, double-check - pub fn negate_mut(&mut self) -> &mut Self { - *self = self.negate(); - self - } - - /// Construct a [`CoordField`] from five raw limbs, value - /// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204`. - /// - /// No normalization and no range check: the caller owns the invariant that the limbs are inside - /// the magnitude bound the reduction chains assume. - /// - /// Scope: both -- unused today. - // TODO Module consumers should instead have a method to construct from [u64; 4] (or indeed a Nat<4>) - // TODO: Claude-generated, double-check - pub const fn new(x0: i64, x1: i64, x2: i64, x3: i64, x4: i64) -> Self { - Self([x0, x1, x2, x3, x4]) - } + // /// Negates self, component-wise. + // /// + // /// Negating every limb negates the value, since the limb weights `2^(51*i)` are all positive. + // /// Lazily reduced, and note the result has negative limbs, which is legal in this representation + // /// but means it is *not* normalized -- [`Self::normalize`] must run before any encode or + // /// comparison. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn negate(&self) -> Self { + // let [x0, x1, x2, x3, x4] = self.0; + // Self([-x0, -x1, -x2, -x3, -x4]) + // } /// Reduce self to the unique canonical representative of its residue class: limbs all in /// `[0, 2^51)` and the value in `[0, p)`. /// + /// Idempotent. + /// /// Two [`Self::reduce`] passes with `y` and then `-y`, where `y` is bit 254 of the value (bit 50 /// of limb `x4`, since `x4` sits at weight `2^204`). Each pass adds `19 * y` at the bottom /// without a matching removal at the top, so neither pass alone is congruent mod p -- but the /// `+19y` and `-19y` cancel exactly, leaving `value - (c + c') * p` for the two top-carry folds /// `c`, `c'`. /// - /// The nudge is what implements Section 5 of RFC7748's "Implementations MUST accept non-canonical - /// values and process them as if they had been reduced modulo the field prime. The non-canonical - /// values are 2^255 - 19 through 2^255 - 1". Checked empirically: a plain `reduce(0).reduce(0)` - /// returns a non-canonical result for *every* value in `p ..= p + 18` -- exactly that range -- - /// whereas this `reduce(y).reduce(-y)` is exact on all of `[0, 2^255)`, which is the entire range - /// [`Self::decode_u_coordinate`] can produce. It first fails at `2^255`, outside the input domain. + /// Adding `19 * y` in the first pass and removing it in the second makes the top carry fire + /// exactly when the value is `>= p`, since `v >= p` if and only if `v + 19 >= 2^255`. Only values + /// `>= 2^254` can be `>= p`, which is why `y` is bit 254. /// - /// Requires limbs inside [`Self::reduce`]'s magnitude bound. Idempotent. + /// This is what implements Section 5 of RFC7748's "Implementations MUST accept non-canonical + /// values and process them as if they had been reduced modulo the field prime. The non-canonical + /// values are 2^255 - 19 through 2^255 - 1". + /// This `reduce(y).reduce(-y)` is exact on all of `[0, 2^255)`, which is the entire range + /// [`Self::decode_255`] can produce. It first fails at `2^255`, outside the input domain. /// - /// Scope: both. - // TODO: Claude-generated, double-check - pub const fn normalize(&self) -> Self { + /// Requires limbs inside [`Self::reduce`]'s magnitude bound. That bound is the one precondition + /// the [`NormalizedFieldElement25519`] typestate does not enforce. + pub(crate) const fn normalize(&self) -> NormalizedFieldElement25519 { let y = (self.0[4] >> (51 - 1)) & 1; - self.reduce(y).reduce(-y) - } - - /// [`Self::normalize`] in place. - /// - /// Scope: both -- unused today; call sites use [`Self::normalize`] directly - /// (`x25519.rs:174`). - // TODO: Claude-generated, double-check - pub fn normalize_mut(&mut self) -> &mut Self { - *self = self.normalize(); - self + NormalizedFieldElement25519(self.reduce(y).reduce(-y)) } /// The multiplicative identity, 1, in canonical form. - /// - /// Scope: both. In X25519 it seeds `z_2` and `x_3` of the Section 5 ladder (`x25519.rs:128`, - /// `129`). - // TODO: Claude-generated, double-check - pub const fn one() -> Self { + pub(crate) const fn one() -> Self { Self([1, 0, 0, 0, 0]) } - /// The least significant bit of the value. - /// - /// Requires normalized self, and this is the sharpest edge in the group: p is odd, so a value in - /// `[p, 2^255)` reports the parity of `v` rather than of `v - p` -- the answer is wrong, not - /// merely non-canonical. - /// - /// Scope: Ed25519. This is the sign bit of x in an Ed25519 point encoding, Section 5.1.3 of - /// RFC8032. X25519 has no use for it: Section 5 of RFC7748 requires bit 255 of a u-coordinate to - /// be masked off, not populated. - // TODO Better name -- is_odd() ? - // TODO: Claude-generated, double-check - pub const fn parity(&self) -> u8 { - (self.0[0] & 1) as u8 - } - /// Returns `(x^3, x^((p-5)/8))` for `x = self`, by a fixed addition chain. /// /// `(p - 5) / 8 == 2^252 - 3` for `p = 2^255 - 19`, which in binary is 250 ones, a zero, then a @@ -720,13 +412,7 @@ impl CoordField { /// i.e. Fermat. /// * [`Self::sqrt_ratio_var`], which uses `x^((p-5)/8)` directly as the `p == 5 mod 8` square-root /// candidate. - /// - /// Scope: both -- it is the engine under the field inversion X25519 needs, and under Ed25519's - /// point decompression. - // TODO Addition chain macro/function - // TODO: Claude-generated, double-check const fn pow_p_sub5_div8(&self) -> (Self, Self) { - // TODO -- where is this from? // z = x^((p-5)/8) = x^FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD // (250 1s) (1 0s) (1 1s) // Addition chain: [1] 2 3 5 10 15 25 50 75 125 [250] @@ -760,15 +446,12 @@ impl CoordField { /// ``` /// /// Warning: a single `reduce` is therefore **not** congruent to its input mod p unless `y == 0`. - /// The `19 * y` is a deliberate nudge that only cancels across the pair of calls in + /// The `19 * y` is a deliberate offset that only cancels across the pair of calls in /// [`Self::normalize`], and this function exists to serve that pair -- it is not a general-purpose /// reduction. /// /// Requires limbs inside the magnitude bound the carry chain assumes: `t5 * 19 + x0` must not /// overflow `i64`. - /// - /// Scope: both. - // TODO: Claude-generated, double-check const fn reduce(&self, y: i64) -> Self { let [x0, x1, x2, x3, x4] = self.0; @@ -805,10 +488,6 @@ impl CoordField { /// /// The result is congruent mod p with limbs near 51 bits, but is *not* normalized -- it may still /// exceed p. Call [`Self::normalize`] before encoding or comparing. - /// - /// Scope: both. - // TODO: Claude-generated, double-check - #[inline] const fn reduce_products(p0: i128, p1: i128, p2: i128, p3: i128, p4: i128) -> Self { #[inline(always)] const fn split(x: i128) -> (i64, i64) { @@ -840,12 +519,7 @@ impl CoordField { /// Same shape as [`Self::mul`], but a squaring's cross terms come in equal pairs, so pre-doubling /// `x0..x3` into `y0..y3` lets one product stand for two and drops the count from 25 to 15. The /// `19` fold for the `i + j >= 5` terms is folded into `t3`, `t4` as before. - /// - /// Verified against reference mod-p arithmetic over 20,000 random values. - /// - /// Scope: both. - // TODO: Claude-generated, double-check - pub const fn sqr(&self) -> Self { + pub(crate) const fn sqr(&self) -> Self { #[inline(always)] const fn m(x: i64, y: i64) -> i128 { x as i128 * y as i128 @@ -864,27 +538,10 @@ impl CoordField { ) } - /// [`Self::sqr`] in place. - /// - /// Scope: both. Used throughout the Section 5 ladder (`x25519.rs:144`, `145`, `152`). - // TODO -- why is it pub and now pub(crate) - // TODO: Claude-generated, double-check - pub fn sqr_mut(&mut self) -> &mut Self { - *self = self.sqr(); - self - } - /// Square self `n` times, i.e. raise to the `2^n`. - /// - /// The run-length primitive for the addition chain in [`Self::pow_p_sub5_div8`]: an exponent - /// written as runs of 1-bits is evaluated as alternating `sqr_n` (shift the accumulated bits up) - /// and `mul` (append another run). - /// - /// Scope: both -- it is how the field inversion is reached. - // TODO -- why is it pub and now pub(crate) - // TODO: Claude-generated, double-check - pub const fn sqr_n(&self, mut n: usize) -> Self { - let mut z = *self; + /// Declared const to enable pre-computed tables to be built at compile-time. + pub(crate) const fn sqr_n(&self, mut n: usize) -> Self { + let mut z = Self(self.0); while n > 0 { z = z.sqr(); n -= 1; @@ -892,55 +549,51 @@ impl CoordField { z } - /// Variable-time square root of the ratio `u / v`: returns `Some(x)` with `x^2 == u / v` if that - /// ratio is a quadratic residue, else `None`. - /// - /// The standard `p == 5 mod 8` method (p mod 8 is 5, checked), arranged to need no inversion: the - /// candidate is `x = u * v^3 * (u * v^7)^((p-5)/8)`, and `v * x^2` is then compared against `+u` - /// and `-u` to sort the three cases. If `v * x^2 == -u`, the true root is `x` times a square root - /// of -1, which is the `ROOT_NEG_ONE` constant -- verified to square to `-1 mod p`, and to equal - /// `2^((p-1)/4)`. - /// - /// Verified over 4,000 random `(u, v)`: every returned root satisfies `x^2 == u / v`, and every - /// `None` is a genuine non-residue. - /// - /// Variable time by construction, via [`Self::is_zero_var`]. That is acceptable for point - /// decompression, which runs on public data, and it is why the name carries the `_var` suffix. - /// - /// Scope: Ed25519. Point decompression, Section 5.1.3 of RFC8032 -- recovering x from y. The - /// X25519 ladder never takes a square root; Section 5 of RFC7748 works from the u-coordinate - /// alone and never recovers v. - // TODO -- why is it pub and now pub(crate) - // TODO: Claude-generated, double-check - pub const fn sqrt_ratio_var(u: &Self, v: &Self) -> Option { - let uv = u.mul(v); - let v2 = v.sqr(); - let uv3 = v2.mul(&uv); - let uv7 = v2.sqr().mul(&uv3); - - let (_, w) = uv7.pow_p_sub5_div8(); - let x = w.mul(&uv3); - let vx2 = x.sqr().mul(v); - - let t = vx2.sub(u).normalize(); - - // TODO -- is this doing a conditional on a secret value? - if t.is_zero_var() { - return Some(x); - } - - let t = vx2.add(u).normalize(); - if t.is_zero_var() { - const ROOT_NEG_ONE: CoordField = CoordField([ - -0x0001E4D8B5F15F50, 0x0000D5A5FC8F189E, -0x000010A16342F3A0, -0x00007A6A597FB361, - 0x0002B8324804FC1E, - ]); - - return Some(x.mul(&ROOT_NEG_ONE)); - } - - None - } + // /// Variable-time square root of the ratio `u / v`: returns `Some(x)` with `x^2 == u / v` if that + // /// ratio is a quadratic residue, else `None`. + // /// + // /// The standard `p == 5 mod 8` method (p mod 8 is 5, checked), arranged to need no inversion: the + // /// candidate is `x = u * v^3 * (u * v^7)^((p-5)/8)`, and `v * x^2` is then compared against `+u` + // /// and `-u` to sort the three cases. If `v * x^2 == -u`, the true root is `x` times a square root + // /// of -1, which is the `ROOT_NEG_ONE` constant -- verified to square to `-1 mod p`, and to equal + // /// `2^((p-1)/4)`. + // /// + // /// Verified over 4,000 random `(u, v)`: every returned root satisfies `x^2 == u / v`, and every + // /// `None` is a genuine non-residue. + // /// + // /// Variable time by construction. + // /// That is acceptable for point decompression, which runs on public data, and it is why the + // /// name carries the `_var` suffix. + // // TODO -- double-check the variable-time assumption + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn sqrt_ratio_var(u: &Self, v: &Self) -> Option { + // let uv = u.mul(v); + // let v2 = v.sqr(); + // let uv3 = v2.mul(&uv); + // let uv7 = v2.sqr().mul(&uv3); + // + // let (_, w) = uv7.pow_p_sub5_div8(); + // let x = w.mul(&uv3); + // let vx2 = x.sqr().mul(v); + // + // let t = vx2.sub(u).normalize(); + // + // if t.is_zero().to_bool() { + // return Some(x); + // } + // + // let t = vx2.add(u).normalize(); + // if t.is_zero().to_bool() { + // const ROOT_NEG_ONE: FieldElement25519 = FieldElement25519([ + // -0x0001E4D8B5F15F50, 0x0000D5A5FC8F189E, -0x000010A16342F3A0, -0x00007A6A597FB361, + // 0x0002B8324804FC1E, + // ]); + // + // return Some(x.mul(&ROOT_NEG_ONE)); + // } + // + // None + // } /// Subtract rhs from self, component-wise. /// @@ -948,70 +601,132 @@ impl CoordField { /// the result is congruent mod p but not normalized. Negative limbs are fine here -- /// [`Self::reduce_products`] and [`Self::reduce`] both use arithmetic (sign-propagating) shifts, /// so a negative limb borrows correctly on the next reduction. - /// - /// Scope: both. In X25519 it is the `E = AA - BB` of the Section 5 ladder (`x25519.rs:109`, - /// `147`). - // TODO -- why is this pub and not pub(crate) ? - // TODO: Claude-generated, double-check - pub const fn sub(&self, rhs: &Self) -> Self { + pub(crate) const fn sub(&self, rhs: &Self) -> Self { let [x0, x1, x2, x3, x4] = self.0; let [y0, y1, y2, y3, y4] = rhs.0; Self([x0 - y0, x1 - y1, x2 - y2, x3 - y3, x4 - y4]) } - /// Subtract one from self, i.e. subtract 1 from limb `x0`, returning a new [`CoordField`]. - /// - /// Mirror of [`Self::add_one`]; if `x0` was zero the limb simply goes negative, which the next - /// reduction resolves. - /// - /// Scope: Ed25519 -- unused today; the `(u - 1)/(u + 1)` birational map of Section 4.1 of - /// RFC7748 is what wants it. - // TODO -- why is this pub and not pub(crate) ? - // TODO: Claude-generated, double-check - pub const fn sub_one(&self) -> Self { - let [x0, x1, x2, x3, x4] = self.0; - Self([x0 - 1, x1, x2, x3, x4]) - } - - /// [`Self::sub_one`] in place. - /// - /// Scope: Ed25519 -- unused today; see [`Self::sub_one`]. - // TODO -- do we really need both versions of this? - // TODO -- why is this pub and not pub(crate) ? - // TODO: Claude-generated, double-check - pub fn sub_one_mut(&mut self) -> &mut Self { - self.0[0] -= 1; - self - } + // /// Subtract one from self, i.e. subtract 1 from limb `x0`, returning a new + // /// [`FieldElement25519`]. + // /// + // /// Mirror of [`Self::add_one`]; if `x0` was zero the limb simply goes negative, which the next + // /// reduction resolves. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn sub_one(&self) -> Self { + // let [x0, x1, x2, x3, x4] = self.0; + // Self([x0 - 1, x1, x2, x3, x4]) + // } /// The additive identity, 0, in canonical form. - /// - /// Scope: both. In X25519 it seeds `z_3` of the Section 5 ladder (`x25519.rs:130`). - // TODO: Claude-generated, double-check - pub const fn zero() -> Self { + pub(crate) const fn zero() -> Self { Self([0; 5]) } } -// TODO -- at least a basic unit test for each math function +/// A [`FieldElement25519`] in canonical form: every limb in `[0, 2^51)` and the value in `[0, p)`. +/// The only constructor is [`FieldElement25519::normalize`]. +/// +// TODO -- impl Deref once a caller needs to continue arithmetic on a +// normalized value (e.g. Ed25519 point decompression). +#[derive(Clone)] +#[must_use] +pub(crate) struct NormalizedFieldElement25519(FieldElement25519); + +impl NormalizedFieldElement25519 { + // /// Constant-time equality of two field elements. + // /// + // /// Compares representations, not residue classes, so it is only correct on the normalized + // /// form: a value and that value plus p have different limbs. + // /// + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn are_equal(lhs: &Self, rhs: &Self) -> Condition { + // let [x0, x1, x2, x3, x4] = lhs.0.0; + // let [y0, y1, y2, y3, y4] = rhs.0.0; + // Condition::::is_zero((x0 ^ y0) | (x1 ^ y1) | (x2 ^ y2) | (x3 ^ y3) | (x4 ^ y4)) + // } + + /// Encodes the field element as a 255-bit value in 32 little-endian bytes; the inverse of + /// [`FieldElement25519::decode_255`]. Bit 255 (the most significant bit of the last byte) is + /// always written as zero. + /// + /// Callers that need that bit to carry something else -- the + /// sign of x in an Ed25519 point encoding (Section 5.1.3 of RFC 8032) -- must OR it in after. + /// + /// Input: self; a 32-byte output buffer. + /// Output: The value written to `bytes` as a 255-bit little-endian integer. + pub(crate) fn encode_255(&self, bytes: &mut [u8; 32]) { + let [w0, w1, w2, w3] = self.encode64_255(); + let (a, b, c, d) = (w0.to_le_bytes(), w1.to_le_bytes(), w2.to_le_bytes(), w3.to_le_bytes()); + // Each word fills a fixed 8-byte window, least significant word first. Every index is a + // constant into a fixed-size array, so an out-of-bounds index would be a compile error (the + // deny-by-default `unconditional_panic` lint) and no runtime bounds check survives. + *bytes = [ + a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], // + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], // + c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], // + d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], + ]; + } -// TODO -- from RFC7748 s. 5: -// u-coordinate array: -// When receiving such an array, implementations of X25519 -// (but not X448) MUST mask the most significant bit in the final byte. -// This is done to preserve compatibility with point formats that -// reserve the sign bit for use in other protocols and to increase -// resistance to implementation fingerprinting. -// + /// Packs the five 51-bit limbs into a 255-bit little-endian integer held as four 64-bit words, + /// each word taking a whole limb plus the low bits of the next. Inverse of the `decode64_255` + /// step inside [`FieldElement25519::decode_255`]. Bit 255 of the result is always zero, since + /// limb `x4` lands in bits 204..254. + /// + /// Relies on the normalized form: every limb in [0, 2^51) and the value reduced mod p. The + /// splices below are `|`, not `+`, so a limb with bits at or above 51 collides with the next + /// limb's bits instead of carrying into them, and the result is garbage. (The `x1 << 51` etc. + /// deliberately shift bits off the top of the i64 -- those are the bits the previous word + /// already carried.) + /// + /// Input: self. + /// Output: The value as four little-endian 64-bit words, least significant word first. + const fn encode64_255(&self) -> [u64; 4] { + let [x0, x1, x2, x3, x4] = self.0.0; + let z0 = (x0 | x1 << 51) as u64; + let z1 = (x1 >> 13 | x2 << 38) as u64; + let z2 = (x2 >> 26 | x3 << 25) as u64; + let z3 = (x3 >> 39 | x4 << 12) as u64; + [z0, z1, z2, z3] + } -// TODO -- from RFC7748 s. 5: -// Implementations MUST accept non-canonical values and process them as -// if they had been reduced modulo the field prime. The non-canonical -// values are 2^255 - 19 through 2^255 - 1 for X25519 and 2^448 - 2^224 -// - 1 through 2^448 - 1 for X448. + // /// Constant-time test for the value 1: `is_zero` over `(x0 ^ 1) | x1 | x2 | x3 | x4`. + // /// + // /// Correct only on the normalized form: only the canonical representative of 1 has limbs + // /// `[1, 0, 0, 0, 0]`. + // /// + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn is_one(&self) -> Condition { + // let [x0, x1, x2, x3, x4] = self.0.0; + // let x0 = x0 ^ 1; + // Condition::::is_zero(x0 | x1 | x2 | x3 | x4) + // } + + // /// Constant-time test for the value 0: `is_zero` over the OR of all five limbs. + // /// + // /// Correct only on the normalized form: an un-normalized zero can be represented as p, whose + // /// limbs are not all zero. + // /// + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn is_zero(&self) -> Condition { + // let [x0, x1, x2, x3, x4] = self.0.0; + // Condition::::is_zero(x0 | x1 | x2 | x3 | x4) + // } + + // /// The least significant bit of the value. + // /// + // /// Correct only on the normalized form, and the reason this type exists: p is odd, so an + // /// un-normalized value in `[p, 2^255)` would report the parity of `v` rather than of `v - p` -- + // /// a wrong answer, not merely a non-canonical one. + // // TODO -- add back once we get to implementing Ed25519 + // pub(crate) const fn is_odd(&self) -> u8 { + // (self.0.0[0] & 1) as u8 + // } +} #[cfg(test)] -mod curve25519_tests { +mod tests { use super::*; /// Alice's public key from the X25519 test vector in Section 6.1 of RFC 7748, used here purely @@ -1030,20 +745,20 @@ mod curve25519_tests { [0x7308909F02085, 0x69B8FB16E8A94, 0x4EAFC356BDCFA, 0x275FA0D1C1306, 0x6A4E9BAA8EA9A]; #[test] - fn decode_u_coordinate_known_value() { - assert_eq!(CoordField::decode_u_coordinate(&KNOWN_VALUE).0, KNOWN_LIMBS); + fn decode_255_known_value() { + assert_eq!(FieldElement25519::decode_255(&KNOWN_VALUE).0, KNOWN_LIMBS); // Bit 255 is not part of the field element, so setting it must not change the result. let mut bit_255_set = KNOWN_VALUE; bit_255_set[31] |= 0x80; - assert_eq!(CoordField::decode_u_coordinate(&bit_255_set).0, KNOWN_LIMBS); + assert_eq!(FieldElement25519::decode_255(&bit_255_set).0, KNOWN_LIMBS); } #[test] fn encode_255_known_value() { // Start from a non-zero buffer so a partial write cannot pass by accident. let mut bytes = [0xFF_u8; 32]; - CoordField(KNOWN_LIMBS).encode_255(&mut bytes); + FieldElement25519(KNOWN_LIMBS).normalize().encode_255(&mut bytes); assert_eq!(bytes, KNOWN_VALUE); } } diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 665e0d512..7287b57b3 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -1,6 +1,6 @@ use bouncycastle_utils::ct::Condition; -use crate::curve25519::CoordField; +use crate::curve25519::FieldElement25519; // TODO -- I would expect that a bunch of this is shared with Ed25519 and should be moved to curve25519.rs // but I'll wait until I get to the Ed25519 implementation and see what's shared. @@ -101,15 +101,44 @@ fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { n[7] |= 0x40000000_u32; } +/// `decodeUCoordinate(u, 255)` as defined in Section 5 of RFC7748: +/// +/// ```text +/// def decodeUCoordinate(u, bits): +/// u_list = [ord(b) for b in u] +/// # Ignore any unused bits. +/// if bits % 8: +/// u_list[-1] &= (1<<(bits%8))-1 +/// return decodeLittleEndian(u_list, bits) +/// ``` +/// +/// With `bits` = 255, `bits % 8` is 7 and the mask clears bit 255, the most significant bit of +/// the final byte. Section 5 requires exactly that: "When receiving such an array, +/// implementations of X25519 (but not X448) MUST mask the most significant bit in the final +/// byte." [`FieldElement25519::decode_255`] does the masking and the little-endian decode. +/// +/// The result is not normalized, as Section 5 also requires: "Implementations MUST accept +/// non-canonical values and process them as if they had been reduced modulo the field prime. The +/// non-canonical values are 2^255 - 19 through 2^255 - 1 for X25519". +/// +/// Verified equal to `decodeUCoordinate(b, 255)` over 20,004 inputs, random plus edge cases. +/// +/// Input: A 32-byte little-endian u-coordinate. +/// Output: The u-coordinate as a [`FieldElement25519`], not normalized. +// TODO: Claude-generated, double-check +fn decode_u_coordinate(u: &[u8; POINT_SIZE]) -> FieldElement25519 { + FieldElement25519::decode_255(u) +} + /// `encodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. -/// The RFC's `u = u % p` step is [`CoordField::normalize`]: an X25519 output must be the canonical -/// encoding of its residue class, even though non-canonical values MUST be accepted on the way in -/// (see [`decode_u_coordinate`]). Bit 255 of the final byte is written as zero, since it is unused -/// by X25519. +/// The RFC's `u = u % p` step is [`FieldElement25519::normalize`]: an X25519 output must be the +/// canonical encoding of its residue class, even though non-canonical values MUST be accepted on +/// the way in (see [`decode_u_coordinate`]). Bit 255 of the final byte is written as zero, since +/// it is unused by X25519. /// Input: A u-coordinate, in any state of reduction; a 32-byte output buffer. /// Output: The canonical 32-byte little-endian encoding of that value, written to `r`. -// TODO -- added by Claude; need to check and use. -fn encode_u_coordinate(u: &CoordField, r: &mut [u8; POINT_SIZE]) { +// TODO: Claude-generated, double-check +fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_SIZE]) { u.normalize().encode_255(r); } @@ -128,10 +157,10 @@ pub fn generate_public_key(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { } // TODO: move to curve25519.rs ? -fn point_double(x: &mut CoordField, z: &mut CoordField) { +fn point_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { let (mut a, mut b) = x.sum_and_difference(z); - a.sqr_mut(); - b.sqr_mut(); + a = a.sqr(); + b = b.sqr(); *x = a.mul(&b); a = a.sub(&b); @@ -151,7 +180,7 @@ pub fn precompute() { /// multiplication on curve25519 from a u-coordinate alone. /// /// The ladder is constant time with respect to `k`: the iteration count is fixed and the only -/// scalar-dependent step is [`CoordField::cswap`]. +/// scalar-dependent step is [`FieldElement25519::cswap`]. /// /// # Modifications from the RFC /// Modified to fold the RFC's first ladder iteration into the initial state, to replace its last @@ -177,11 +206,11 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT debug_assert_eq!(1, n[7] >> 30); debug_assert_eq!(0, n[0] & 7); - let x1 = CoordField::decode_u_coordinate(u); - let mut x2 = x1; - let mut z2 = CoordField::one(); - let mut x3 = CoordField::one(); - let mut z3 = CoordField::zero(); + let x1 = decode_u_coordinate(u); + let mut x2 = x1.clone(); + let mut z2 = FieldElement25519::one(); + let mut x3 = FieldElement25519::one(); + let mut z3 = FieldElement25519::zero(); let mut t1; let mut t2; @@ -220,9 +249,9 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT // DA = D * A, -> x3 x3 = x3.mul(&z3); // AA = A^2, -> z3 - z3.sqr_mut(); + z3 = z3.sqr(); // BB = B^2, -> x2 - x2.sqr_mut(); + x2 = x2.sqr(); // E = AA - BB, -> t2 t2 = z3.sub(&x2); @@ -241,7 +270,7 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT // Deviation from the RFC: yields CB - DA, negated. Harmless as both lines below square it. (x3, z3) = t1.sum_and_difference(&x3); // (completes x_3) - x3.sqr_mut(); + x3 = x3.sqr(); // (completes z_3) z3 = z3.sqr().mul(&x1); @@ -258,10 +287,10 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT swap ^= k_bit; // (x_2, x_3) = cswap(swap, x_2, x_3) // > swaps in-place - CoordField::cswap(swap, &mut x2, &mut x3); + FieldElement25519::cswap(swap, &mut x2, &mut x3); // (z_2, z_3) = cswap(swap, z_2, z_3) // > swaps in-place - CoordField::cswap(swap, &mut z2, &mut z3); + FieldElement25519::cswap(swap, &mut z2, &mut z3); // swap = k_t swap = k_bit; } @@ -273,7 +302,7 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT } x2 = x2.mul(&z2.inv()); - x2.normalize().encode_255(out); + encode_u_coordinate(&x2, out); } fn scalar_mult_base(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 50e4ba288..8d4c25249 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -1,7 +1,5 @@ -use bouncycastle_core; use bouncycastle_ed::x25519::*; use bouncycastle_hex as hex; -use bouncycastle_rng; // todo -- debug and turn back on // #[test] From f182bb138efb9650c13bcd6f96c71603517f6ae9 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 21:51:12 -0500 Subject: [PATCH 09/26] Progress on x25519 --- crypto/ed/src/x25519.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 7287b57b3..19bfb5b27 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -21,16 +21,6 @@ const C_A24: u32 = (C_A + 2) / 4; // TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait -// TODO -- RFC7748: "When receiving such an array, implementations of X25519 -// (but not X448) MUST mask the most significant bit in the final byte." -// I think "mask" here means to assign 0 or 1 pseudo-randomly? - -// TODO -- RFC7748: " Implementations MUST accept non-canonical values and process them as -// if they had been reduced modulo the field prime. The non-canonical -// values are 2^255 - 19 through 2^255 - 1 for X25519 and 2^448 - 2^224 -// - 1 through 2^448 - 1 for X448. -// The TODO here is to unit test this. - fn are_all_zeros(r: &[u8]) -> bool { let mut bits = 0; for i in r.iter() { @@ -39,9 +29,7 @@ fn are_all_zeros(r: &[u8]) -> bool { bits == 0 } -// TODO -- what is this and where is it from? -// TODO -- why is it pub and now pub(crate) -pub fn calculate_agreement( +fn calculate_agreement( k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], r: &mut [u8; POINT_SIZE], From 07b66290ddb54dab522e3ba45c9c0259163b2ffb Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 22:25:28 -0500 Subject: [PATCH 10/26] Progress on x25519 --- crypto/ed/ed_todo.md | 14 ++- crypto/ed/src/curve25519.rs | 28 ----- crypto/ed/src/x25519.rs | 157 ++++++++++++++++----------- crypto/ed/tests/x25519_tests.rs | 46 ++++---- crypto/ed/tests/x25519_wycheproof.rs | 5 +- 5 files changed, 131 insertions(+), 119 deletions(-) diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 42a5d3b1f..3eaa30fbc 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -71,9 +71,9 @@ What the analysis found: non-canonical input; the Wycheproof suite now covers both. - [x] Add the RFC 7748 §6.1 Diffie-Hellman vectors: Alice's and Bob's public keys and the shared secret K. -- [ ] Label `vector_1`/`vector_2` and the iterated test as RFC 7748 §5.2 (the comment says - "RFC7749"), and drop the "what is this?" TODO on `check_iterated`; it is the §5.2 iterated - test. Fix the stale comment on `scalar_mult` that says these vectors are not exercised. +- [x] Document `check_iterated` as the RFC 7748 §5.2 iterated test. +- [x] Label `vector_1`/`vector_2` as RFC 7748 §5.2, and drop the stale comment on `scalar_mult` + that said these vectors are not exercised. - [ ] Re-enable the `agreement` round-trip test. It currently calls a non-existent `generate_private_key_rng`. - [ ] Remove the unused imports (`bouncycastle_core`, `bouncycastle_rng`) and the commented-out @@ -88,7 +88,7 @@ What the analysis found: - [ ] `normalize`'s own input bound (limbs within `reduce`'s magnitude limit) is still only documented; debug-only magnitude tracking would check it in tests. - [x] Identify `C_A` (486662) and `C_A24` and document them against RFC 7748. -- [ ] Remove the dead code: `Scalar25519`/`UCoordinate25519` (`todo!()`), +- [ ] Remove the dead code: `P32`, `P64`, and the empty `precompute()`. Or finish them, if Phase 3 wants them. - [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). @@ -121,7 +121,7 @@ What the analysis found: - [ ] Make `calculate_agreement` (and the other `x25519.rs` free functions) `pub(crate)` once the Phase 2 trait API replaces them; the tests in `tests/` call them today. - [ ] `FieldElement25519` cleanups: - - [ ] `[Secret; 5]`? Block `Debug`? Prune the derive list. + - [x] `[Secret; 5]`? Block `Debug`? Prune the derive list. - [x] In-place methods (`*_mut`, `cmov`, `cnegate`) return `()`, not `&mut Self`. - [ ] Do we need both value and `_mut` forms? Only `sqr_mut` has a caller today. - [ ] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. @@ -174,7 +174,9 @@ types, and carry each X25519 item across. Only the differences are listed here. - [ ] Port from `bc-rust-nursery/crates/{curve25519,ed25519}` onto the shared `FieldElement25519`: Edwards point arithmetic, point encoding/decoding, and SHA-512 from `bouncycastle-sha2`. -- [ ] Move what X25519 shares with Ed25519 (e.g. `point_double`) into `curve25519.rs`. +- [ ] Move what X25519 shares with Ed25519 (e.g. curve constants) into `curve25519.rs`. Not + `ladder_double`: that is Montgomery u-only doubling, while Ed25519 doubles in extended + coordinates (RFC 8032 §5.1.4). - [ ] Pure Ed25519: RFC 8032 §7.1 vectors. - [ ] Ed25519ctx: RFC 8032 §7.2 vectors. - [ ] Ed25519ph: RFC 8032 §7.3 vectors. diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index bfd67a8c4..7e222231a 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -1,33 +1,5 @@ use bouncycastle_utils::ct::Condition; -/// The encoded u-coordinate of a point on curve25519: 32 little-endian bytes, as defined in -/// Section 5 of RFC7748. See [`FieldElement25519`] for the field it encodes an element of. -struct UCoordinate25519(Scalar25519); - -/// Scalar values are integers used as the k value in scalar multiplication of points. -/// -/// # Mathematical operations implemented: -/// -/// * EC scalar multiplication: k * A: via `impl core::ops::Mul for Scalar25519`, with `Output = UCoordinate25519` -struct Scalar25519([u8; 32]); - -impl core::ops::Mul for Scalar25519 { - type Output = UCoordinate25519; - - /// EC scalar multiplication `k * A`, computed by the Montgomery ladder of Section 5 of - /// RFC7748; ie this is the X25519 function itself. - /// - /// Input: self, the scalar `k`; rhs, the u-coordinate of a point `A`. - /// Output: the u-coordinate of `k * A`. - /// - /// Scope: X25519. - // TODO -- unimplemented. `todo!()` rather than a literally empty body because `mul` has to - // produce a `[u8; 32]`. - fn mul(self, rhs: UCoordinate25519) -> Self::Output { - todo!() - } -} - /// Mask for one limb: `2^51 - 1`. A [`FieldElement25519`] holds the value /// `x0 + x1*2^51 + x2*2^102 + x3*2^153 + x4*2^204`, so `& M51` extracts a limb's own 51 bits and /// leaves the overflow to be carried into the next limb (or, out of `x4`, folded back into `x0` diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 19bfb5b27..f0bae6c94 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -1,13 +1,60 @@ use bouncycastle_utils::ct::Condition; +use bouncycastle_utils::secret::Secret; use crate::curve25519::FieldElement25519; -// TODO -- I would expect that a bunch of this is shared with Ed25519 and should be moved to curve25519.rs -// but I'll wait until I get to the Ed25519 implementation and see what's shared. +// Only the field arithmetic (`FieldElement25519` in curve25519.rs) is shared with Ed25519. The +// scalar and point types below are X25519's own: an Ed25519 scalar is reduced mod the group order +// and an Ed25519 point is an encoded y-coordinate plus a sign bit (RFC 8032 §5.1.2), so neither +// fits these. pub const POINT_SIZE: usize = 32; pub const SCALAR_SIZE: usize = 32; +/// An X25519 private key: a 32-byte scalar, as defined in Section 5 of RFC7748. +/// +/// Any 32 bytes are a valid scalar. The fixed bits `decodeScalar25519` sets and clears are applied +/// each time the scalar is used, not on construction, so the stored bytes are exactly the ones the +/// caller supplied. Held in a [`Secret`], so it is zeroized on drop and redacted from `Debug`. +pub struct X25519Scalar(Secret<[u8; SCALAR_SIZE]>); + +impl X25519Scalar { + /// Copies 32 bytes into a new scalar. The copy lives in zeroizing storage; the caller's buffer + /// is not cleared. + pub fn from_bytes(bytes: &[u8; SCALAR_SIZE]) -> Self { + let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + *k = *bytes; + Self(k) + } +} + +/// An X25519 public value: the u-coordinate of a point on curve25519, as 32 little-endian bytes +/// (Section 5 of RFC7748). +/// +/// Any 32 bytes are accepted: Section 5 requires bit 255 to be ignored and values `>= p` to be +/// processed as if reduced mod p. Public data, so it is `Copy` and its `Debug` shows the bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct X25519Point([u8; POINT_SIZE]); + +impl X25519Point { + /// Wraps 32 bytes as a u-coordinate. + pub const fn from_bytes(bytes: [u8; POINT_SIZE]) -> Self { + Self(bytes) + } + + /// The 32 encoded bytes. + pub const fn as_bytes(&self) -> &[u8; POINT_SIZE] { + &self.0 + } +} + +/// The base point of curve25519, `U(P) = 9` (Section 4.1 of RFC7748). +const BASE_POINT: X25519Point = { + let mut u = [0_u8; POINT_SIZE]; + u[0] = 9; + X25519Point(u) +}; + /// The coefficient A of curve25519, `v^2 = u^3 + A*u^2 + u` (RFC 7748 §4.1). const C_A: u32 = 486662; @@ -16,7 +63,7 @@ const C_A: u32 = 486662; /// Deviation from the RFC: RFC 7748 §5 uses `a24 = (486662 - 2) / 4 = 121665` in /// `z_2 = E * (AA + a24 * E)`. Since `E = AA - BB`, `AA + a24 * E = BB + (a24 + 1) * E`, so pairing /// the constant with BB instead needs `a24 + 1 = (A + 2) / 4`. [`scalar_mult`] and -/// [`point_double`] both use that form. +/// [`ladder_double`] both use that form. const C_A24: u32 = (C_A + 2) / 4; // TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait @@ -29,19 +76,13 @@ fn are_all_zeros(r: &[u8]) -> bool { bits == 0 } -fn calculate_agreement( - k: &[u8; SCALAR_SIZE], - u: &[u8; POINT_SIZE], - r: &mut [u8; POINT_SIZE], -) -> bool { +// TODO -- remove pub visibility once I have the traits impl'd +// TODO -- the shared secret is still written to bare bytes; Phase 2 decides its type (KeyMaterial). +pub fn calculate_agreement(k: &X25519Scalar, u: &X25519Point, r: &mut [u8; POINT_SIZE]) -> bool { scalar_mult(k, u, r); !are_all_zeros(&r[..POINT_SIZE]) } -fn decode_u32(bs: &[u8]) -> u32 { - (bs[0] as u32) | (bs[1] as u32) << 8 | (bs[2] as u32) << 16 | (bs[3] as u32) << 24 -} - /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: /// /// ```text @@ -58,30 +99,25 @@ fn decode_u32(bs: &[u8]) -> u32 { /// the last byte to 1 and, finally, decode as little-endian. This means that the resulting /// integer is of the form 2^254 plus eight times a value between 0 and 2^251 - 1 (inclusive)." /// -/// That last sentence is the invariant [`scalar_mult`] is built on, and it needs both halves of -/// it. The `2^254 +` is why the ladder can start at bit 253 against a pre-swapped initial state; -/// the `8 *` is why its last three iterations collapse into three [`point_double`] calls with no +/// That is why the ladder can start at bit 253 against a pre-swapped initial state; +/// the `8 *` is why its last three iterations collapse into three [`ladder_double`] calls with no /// trailing conditional swap. /// /// Deviation from the RFC: the RFC masks bytes and then decodes, whereas this decodes into /// four-byte words first and masks the equivalent word positions. `k_list[0] &= 248` becomes /// `n[0] &= 0xFFFFFFF8` -- the same low three bits, since `n[0]` is the first four bytes -- and /// `k_list[31] &= 127` / `|= 64` become `n[7] &= 0x7FFFFFFF` / `|= 0x40000000`, the same top two -/// bits, since `n[7]` is the last four bytes. Verified equal to `decodeScalar25519` over 20,004 -/// inputs, random plus edge cases. +/// bits, since `n[7]` is the last four bytes. /// -/// The `25519` in the name is load-bearing: `decodeScalar448` clears two low bits rather than +/// This differs from `decodeScalar448`, which clears two low bits rather than /// three, and sets bit 447 (Section 5). /// /// Input: A 32-byte little-endian scalar. /// Output: The decoded scalar, written to `n` as eight little-endian 32-bit words. -/// -/// Scope: X25519. -// TODO: Claude-generated, double-check -fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { - for (i, n_i) in n.iter_mut().enumerate() { - let pos = i * 4; - *n_i = decode_u32(&k[pos..(pos + 4)]); +fn decode_scalar_25519(k: &X25519Scalar, n: &mut [u32; 8]) { + let (words, _) = k.0.as_chunks::<4>(); // 32 bytes: exactly 8 chunks, no remainder + for (n_i, w) in n.iter_mut().zip(words) { + *n_i = u32::from_le_bytes(*w); } n[0] &= 0xFFFFFFF8_u32; @@ -113,9 +149,8 @@ fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { /// /// Input: A 32-byte little-endian u-coordinate. /// Output: The u-coordinate as a [`FieldElement25519`], not normalized. -// TODO: Claude-generated, double-check -fn decode_u_coordinate(u: &[u8; POINT_SIZE]) -> FieldElement25519 { - FieldElement25519::decode_255(u) +fn decode_u_coordinate(u: &X25519Point) -> FieldElement25519 { + FieldElement25519::decode_255(&u.0) } /// `encodeUCoordinate(u, 255)` as defined in Section 5 of RFC 7748. @@ -123,9 +158,9 @@ fn decode_u_coordinate(u: &[u8; POINT_SIZE]) -> FieldElement25519 { /// canonical encoding of its residue class, even though non-canonical values MUST be accepted on /// the way in (see [`decode_u_coordinate`]). Bit 255 of the final byte is written as zero, since /// it is unused by X25519. +/// /// Input: A u-coordinate, in any state of reduction; a 32-byte output buffer. /// Output: The canonical 32-byte little-endian encoding of that value, written to `r`. -// TODO: Claude-generated, double-check fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_SIZE]) { u.normalize().encode_255(r); } @@ -140,12 +175,28 @@ fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_SIZE]) { // } // TODO make this match the style of MLDSA keygen -pub fn generate_public_key(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { - scalar_mult_base(k, r); +pub fn generate_public_key(k: &X25519Scalar) -> X25519Point { + let mut u = [0_u8; POINT_SIZE]; + scalar_mult_base(k, &mut u); + X25519Point(u) } -// TODO: move to curve25519.rs ? -fn point_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { +/// Doubles one point, held projectively as the pair `(x : z)` representing `u = x / z` +/// Updated in place. +/// +/// Keeping u as a fraction is what lets the ladder avoid a field inversion on every step: only +/// multiplications and squarings are needed here, and [`scalar_mult`] divides `x` by `z` once, at +/// the end. +/// +/// This is the doubling half of the Montgomery ladder step in Section 5 of RFC7748, used by +/// [`scalar_mult`] for the last three iterations. With `AA = (x + z)^2`, `BB = (x - z)^2` and +/// `E = AA - BB`, it writes `x = AA * BB` and `z = E * (BB + C_A24 * E)`; see [`C_A24`] for the +/// pairing with `BB`. +/// +/// X25519 only: it works on curve25519's Montgomery form and depends on its coefficient A. +/// Ed25519 doubles points on the twisted Edwards curve in extended coordinates (Section 5.1.4 of +/// RFC8032), which is a separate function. +fn ladder_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { let (mut a, mut b) = x.sum_and_difference(z); a = a.sqr(); b = b.sqr(); @@ -156,14 +207,6 @@ fn point_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { *z = t.mul(&a); } -// TODO -- precompute what? -pub fn precompute() { - // TODO (or remove if precomputation inlined to static) - // curve25519::precompute(); -} - -// TODO -- "For X25519, the unused, most significant bit MUST be zero." -- add test case or debug_assert. - /// `X25519(k, u)` as defined in Section 5 of RFC7748: the Montgomery ladder, computing scalar /// multiplication on curve25519 from a u-coordinate alone. /// @@ -172,7 +215,7 @@ pub fn precompute() { /// /// # Modifications from the RFC /// Modified to fold the RFC's first ladder iteration into the initial state, to replace its last -/// three iterations with [`point_double`] calls, and to cycle the RFC's named intermediates +/// three iterations with [`ladder_double`] calls, and to cycle the RFC's named intermediates /// through a fixed set of registers rather than materialise them. /// /// # Inputs @@ -180,12 +223,9 @@ pub fn precompute() { /// * `u`, a 32-byte little-endian u-coordinate /// * `out`, a 32-byte output buffer. /// Output: The canonical little-endian encoding of the u-coordinate of `k * u`, written to `out`. -// TODO -- the Section 5.2 and Section 6.1 test vectors are not exercised anywhere in this crate -// yet. The structure here was checked against them out-of-tree, which is not the same -// thing as a test. -// TODO: Claude-generated, double-check -fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT_SIZE]) { - let mut n = [0u32; 8]; +fn scalar_mult(k: &X25519Scalar, u: &X25519Point, out: &mut [u8; POINT_SIZE]) { + // The decoded scalar is as secret as `k` itself. + let mut n: Secret<[u32; 8]> = Secret::new(); decode_scalar_25519(k, &mut n); // Pre-condition: @@ -216,7 +256,7 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT // z_3 = 1 *after* it has been applied, and `swap` starts at k_254. So the loop starts at 253 and not 254. // 3. t = 2, 1, 0 are not in the loop because bits 0..2 are always clear, so those iterations reduce // to doubling the (x_2, z_2) branch and their swaps are no-ops. They become the three - // point_double calls after the loop, and the RFC's post-loop cswap is dropped for the same + // ladder_double calls after the loop, and the RFC's post-loop cswap is dropped for the same // reason -- the debug_assert below is what checks it would have been a no-op. for bit in (2..=253_usize).rev() { // This section is the same core algorithm as in RFC7748 §5, but uses a different variable @@ -262,8 +302,6 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT // (completes z_3) z3 = z3.sqr().mul(&x1); - // The swap block is a statement-for-statement transcription, just its placement within the - // loop has been changed. // This differs from the ladder presentation in the RFC because it implements the // constant-time cswap recommendation at the bottom of §5 using an all-ones/all-zeros // mask and the Condition type. @@ -286,22 +324,15 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &[u8; POINT_SIZE], out: &mut [u8; POINT debug_assert!(!swap.to_bool()); for _ in 0..3 { - point_double(&mut x2, &mut z2); + ladder_double(&mut x2, &mut z2); } x2 = x2.mul(&z2.inv()); encode_u_coordinate(&x2, out); } -fn scalar_mult_base(k: &[u8; SCALAR_SIZE], r: &mut [u8; POINT_SIZE]) { - // Equivalent (but much slower) - let mut u = [0_u8; POINT_SIZE]; - u[0] = 9; - scalar_mult(k, &u, r); - - // TODO - // let (y, z) = curve25519::scalar_mult_base_yz(k); - // let (t, u) = z.sum_and_difference(&y); - // let v = t.mul(&u.inv()); - // v.normalize().encode(r); +fn scalar_mult_base(k: &X25519Scalar, r: &mut [u8; POINT_SIZE]) { + // Equivalent (but much slower) -- since the BASE_POINT never changes, you can + // precompute lookup tables for the multiples of BASE_POINT. TODO + scalar_mult(k, &BASE_POINT, r); } diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 8d4c25249..d5925b44f 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -37,17 +37,16 @@ use bouncycastle_hex as hex; #[test] fn iterated() { - check_iterated(1000); + rfc7748_check_iterated(1000); } #[ignore] #[test] fn iterated_full() { - check_iterated(1000000); + rfc7748_check_iterated(1000000); } -// TODO -- this appears to be the vector from RFC7749 s.5.2 -// If so, document that. +/// The first X25519 single-call test vector of RFC 7748 Section 5.2. #[test] fn vector_1() { check_vector( @@ -58,6 +57,7 @@ fn vector_1() { ); } +/// The second X25519 single-call test vector of RFC 7748 Section 5.2. #[test] fn vector_2() { check_vector( @@ -72,20 +72,22 @@ fn vector_2() { /// shared secret K that each derives from their own private key and the other's public key. #[test] fn rfc7748_section_6_1() { - let alice_sk = decode_32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"); - let bob_sk = decode_32("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"); - - let mut alice_pk = [0_u8; POINT_SIZE]; - let mut bob_pk = [0_u8; POINT_SIZE]; - generate_public_key(&alice_sk, &mut alice_pk); - generate_public_key(&bob_sk, &mut bob_pk); + let alice_sk = X25519Scalar::from_bytes(&decode_32( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + )); + let bob_sk = X25519Scalar::from_bytes(&decode_32( + "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb", + )); + + let alice_pk = generate_public_key(&alice_sk); + let bob_pk = generate_public_key(&bob_sk); check_value( - &alice_pk, + alice_pk.as_bytes(), "Section 6.1 Alice's public key", "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", ); check_value( - &bob_pk, + bob_pk.as_bytes(), "Section 6.1 Bob's public key", "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", ); @@ -103,13 +105,15 @@ fn decode_32(s: &str) -> [u8; 32] { hex::decode(s).unwrap().try_into().unwrap() } -// TODO -- what on earth is this? We're going to key_agree this with itself 1 million times? -// Why? What is this testing that's not already covered by the regular KATs? -fn check_iterated(count: usize) { +/// The iterated test of RFC 7748 Section 5.2: starting from k = u = 9, each iteration sets k to +/// `X25519(k, u)` and u to the old k, and k is checked after 1, 1,000 and 1,000,000 iterations. +/// +/// Feeding each output back in as the next scalar and u-coordinate exercises the ladder, field +/// arithmetic and encodings on `count` effectively random inputs against a single known answer, +/// and one wrong step corrupts every value after it. The single-call vectors cover two inputs. +fn rfc7748_check_iterated(count: usize) { assert_eq!(POINT_SIZE, SCALAR_SIZE); - precompute(); - let mut k = [0_u8; POINT_SIZE]; k[0] = 9; let mut u = [0_u8; POINT_SIZE]; @@ -118,7 +122,7 @@ fn check_iterated(count: usize) { let mut iterations = 0; while iterations < count { - calculate_agreement(&k, &u, &mut r); + calculate_agreement(&X25519Scalar::from_bytes(&k), &X25519Point::from_bytes(u), &mut r); u = k; k = r; @@ -152,8 +156,8 @@ fn check_value(n: &[u8], text: &str, se: &str) { } fn check_vector(sk: &str, su: &str, se: &str, text: &str) { - let k: [u8; SCALAR_SIZE] = hex::decode(sk).unwrap().try_into().unwrap(); - let u: [u8; POINT_SIZE] = hex::decode(su).unwrap().try_into().unwrap(); + let k = X25519Scalar::from_bytes(&decode_32(sk)); + let u = X25519Point::from_bytes(decode_32(su)); let mut r = [0_u8; POINT_SIZE]; calculate_agreement(&k, &u, &mut r); diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index d68420a37..f96d32de0 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -12,7 +12,9 @@ //! `calculate_agreement` returns `false`. use bouncycastle_core_test_framework::test_data_loaders::{Value, hex_field, wycheproof_json}; -use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, calculate_agreement}; +use bouncycastle_ed::x25519::{ + POINT_SIZE, SCALAR_SIZE, X25519Point, X25519Scalar, calculate_agreement, +}; #[test] fn wycheproof_x25519() { @@ -41,6 +43,7 @@ fn wycheproof_x25519() { let shared = hex_field(test, "shared", tc_id); let mut out = [0u8; POINT_SIZE]; + let (k, u) = (X25519Scalar::from_bytes(&k), X25519Point::from_bytes(u)); let nonzero = calculate_agreement(&k, &u, &mut out); assert_eq!(out.as_slice(), shared.as_slice(), "{ctx}: shared secret"); assert_eq!(nonzero, shared.iter().any(|&b| b != 0), "{ctx}: return value"); From 6b1df219325089b6ceb3cb3444a956ce5df7ac8f Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 22:36:22 -0500 Subject: [PATCH 11/26] Progress on x25519 --- crypto/ed/ed_todo.md | 86 +++++++++----------------------- crypto/ed/src/curve25519.rs | 88 ++++++++++++++++++++++++++++++++- crypto/ed/tests/x25519_tests.rs | 6 --- 3 files changed, 109 insertions(+), 71 deletions(-) diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 3eaa30fbc..cd6cd3f0a 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -17,79 +17,31 @@ Within each, the priority order is: # X25519 (RFC 7748) -## Status and Gap analysis (2026-10-28) - -Status as of 2026-10-28: X25519 passes every RFC 7748 vector and all 518 Wycheproof -`x25519_test.json` vectors, all checked in as tests (`x25519_tests.rs`, -`x25519_wycheproof.rs`). X448, Ed25519 and Ed448 are not started on this -branch; `../bc-rust-nursery/crates/{x448,ed25519,ed448,curve448}` hold earlier work to port from. -bc-test-data has no vectors for any of the four. - -The vector-suite loaders will skip silently here: the local bc-test-data checkout is named -`bc-test-data.donttest`, so `core-test-framework`'s loaders would skip it with a warning. That has -no effect today because it has no vectors for these algorithms, but bear it in mind when adding -suites. - -How each vector source fares on `feature/x25519` at `cf2677ba`. "Checked in" means a test in -`tests/` runs it. - -| Source | X25519 | Checked in? | -|---------------------------------------------|---------------------------------------|-----------------------------| -| RFC 7748 §5.2 single-call vectors (2) | pass | yes: `vector_1`, `vector_2` | -| RFC 7748 §5.2 iterated, 1 and 1,000 rounds | pass | yes: `iterated` | -| RFC 7748 §5.2 iterated, 1,000,000 rounds | pass (~39 s in release) | yes, but `#[ignore]` | -| RFC 7748 §6.1 Diffie-Hellman | pass (both public keys and K) | yes: `rfc7748_section_6_1` | -| Wycheproof `x25519_test.json` | 518/518 output bytes match | yes: `x25519_wycheproof.rs` | -| Wycheproof `x25519_{asn,jwk,pem}_test.json` | not run (needs key decoding, Phase 3) | no | -| bc-test-data | no X25519 vectors exist | n/a | - -What the analysis found: - -- **X25519 arithmetic is correct.** The Wycheproof run covered the flags `EdgeCaseMultiplication`, - `EdgeCasePrivateKey`, `EdgeCaseShared`, `Ktv`, `LowOrderPublic`, `NonCanonicalPublic`, - `Normal`, `SmallPublicKey`, `SpecialPublicKey`, `Twist` and `ZeroSharedSecret`. So the open - TODO comments about RFC §5 masking and non-canonical input describe missing tests, not missing - behaviour. -- **Low-order public keys** produce the all-zero shared secret, which matches Wycheproof's bytes. - `calculate_agreement` returns `false` for these. Wycheproof grades them "acceptable", so - whether to reject them is still an API decision (Phase 2). -- **The X25519 gaps are test coverage and API, not correctness.** The only callable surface is - free functions over `[u8; 32]` (`calculate_agreement`, `generate_public_key`). There is no - private-key generation, and the round-trip test `agreement` is commented out. -- **Build warnings:** 7 dead-code warnings in the lib and 2 unused imports in the test. - ## Phase 1: Functional - [x] Add a Wycheproof suite `tests/x25519_wycheproof.rs` over `x25519_test.json`: 518 vectors, 264 valid and 254 acceptable, 31 with an all-zero shared secret. (maybe need to be renamed / refactored to match other crates) -- [ ] Decide what happens on an all-zero shared secret (low-order public key). Today - `calculate_agreement` outputs zeros and returns `false`; Wycheproof marks these vectors - "acceptable". Reject, or pass through? This maybe requires some research to see if this condition can be detected on - the public inputs before peforming the private key operations (would be better for constan-time concerns). -- [ ] Close the TODO comments in `x25519.rs` and `curve25519.rs` about RFC §5 top-bit masking and +- [x] Close the TODO comments in `x25519.rs` and `curve25519.rs` about RFC §5 top-bit masking and non-canonical input; the Wycheproof suite now covers both. - [x] Add the RFC 7748 §6.1 Diffie-Hellman vectors: Alice's and Bob's public keys and the shared secret K. - [x] Document `check_iterated` as the RFC 7748 §5.2 iterated test. - [x] Label `vector_1`/`vector_2` as RFC 7748 §5.2, and drop the stale comment on `scalar_mult` that said these vectors are not exercised. -- [ ] Re-enable the `agreement` round-trip test. It currently calls a non-existent - `generate_private_key_rng`. -- [ ] Remove the unused imports (`bouncycastle_core`, `bouncycastle_rng`) and the commented-out - `decode_hex` from `x25519_tests.rs`. -- [ ] Review `FieldElement25519` in `curve25519.rs`. Ed25519 reuses it, so get it right here first: - - [ ] Review every method marked `// TODO: Claude-generated, double-check`, plus +- [x] Remove the unused imports (`bouncycastle_core`, `bouncycastle_rng`) from `x25519_tests.rs`. +- [x] Remove the commented-out `decode_hex` from `x25519_tests.rs`. +- [x] Review `FieldElement25519` in `curve25519.rs`. Ed25519 reuses it, so get it right here first: + - [x] Review every method marked `// TODO: Claude-generated, double-check`, plus `decode_scalar_25519` and `scalar_mult` in `x25519.rs`. - - [ ] Think about whether each field-arithmetic function needs a basic unit test. - [x] Enforce the normalization precondition: typestate. `normalize` returns a `NormalizedFieldElement25519`, which alone has `encode_255`, `is_zero`/`is_one`, `parity` and (once restored) `are_equal`. - - [ ] `normalize`'s own input bound (limbs within `reduce`'s magnitude limit) is still only - documented; debug-only magnitude tracking would check it in tests. + - [x] `normalize`'s own input bound: the value in `[0, 2^255)` (limbs may exceed 51 bits), + checked by a `debug_assert` at its top. Output range checked by `NormalizedFieldElement25519::debug_check_range`. - [x] Identify `C_A` (486662) and `C_A24` and document them against RFC 7748. -- [ ] Remove the dead code: - `P32`, `P64`, and the empty `precompute()`. Or finish them, if Phase 3 wants them. +- [x] Remove the dead code: `precompute()` is gone, and `P32`, `P64` and the methods X25519 does + not use are commented out; the crate builds with no warnings. - [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). ## Phase 2: Traits, Bells & Whistles @@ -100,10 +52,17 @@ What the analysis found: const generics); - [ ] the output type, wrapped in `KeyMaterial` (the shared secret is sensitive); - [ ] an error type. -- [ ] Add X25519 key types: `Secret`-backed private keys, zeroized, no `Debug` leak. +- [x] Add X25519 key types: `X25519Scalar` (`Secret`-backed, zeroized, redacted `Debug`) and + `X25519Point`, used by `calculate_agreement` and `generate_public_key`. - [ ] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. +- [ ] Re-enable the `agreement` round-trip test. It currently calls a non-existent + `generate_private_key_rng`. - [ ] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from X25519. +- [ ] Decide what happens on an all-zero shared secret (low-order public key). Today + `calculate_agreement` outputs zeros and returns `false`; Wycheproof marks these vectors + "acceptable". Reject, or pass through? This maybe requires some research to see if this condition can be detected on + the public inputs before peforming the private key operations (would be better for constan-time concerns). - [ ] A `KEM` impl over X25519 (DHKEM-style). Mentioned in an `x25519.rs` TODO; decide if it belongs here. - [ ] Keygen: @@ -114,19 +73,20 @@ What the analysis found: commented-out Edwards-form path. - [ ] Wire into the factory (a `KeyAgreementFactory`, if that fits the factory pattern) and add a CLI subcommand (`cli/src/*_cmd.rs`). +- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). ## Phase 3: Docs and ergonomics - [x] `FieldElement25519` and its methods are `pub(crate)`. - [ ] Make `calculate_agreement` (and the other `x25519.rs` free functions) `pub(crate)` once the Phase 2 trait API replaces them; the tests in `tests/` call them today. -- [ ] `FieldElement25519` cleanups: +- [x] `FieldElement25519` cleanups: - [x] `[Secret; 5]`? Block `Debug`? Prune the derive list. - [x] In-place methods (`*_mut`, `cmov`, `cnegate`) return `()`, not `&mut Self`. - - [ ] Do we need both value and `_mut` forms? Only `sqr_mut` has a caller today. - - [ ] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. - - [ ] Rename `parity` (to `is_odd`?). - - [ ] Remove the answered "why are we cloning?" TODO. + - [x] Do we need both value and `_mut` forms? No: only the value forms remain. + - [x] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. Moot: `new` is removed. + - [x] Rename `parity` (to `is_odd`?). + - [x] Remove the answered "why are we cloning?" TODO. - [ ] Docs: - [ ] Re-enable `#![forbid(missing_docs)]`. - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md. diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index 7e222231a..f4fca7ff6 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -357,9 +357,24 @@ impl FieldElement25519 { /// This `reduce(y).reduce(-y)` is exact on all of `[0, 2^255)`, which is the entire range /// [`Self::decode_255`] can produce. It first fails at `2^255`, outside the input domain. /// - /// Requires limbs inside [`Self::reduce`]'s magnitude bound. That bound is the one precondition - /// the [`NormalizedFieldElement25519`] typestate does not enforce. + /// Requires the value in `[0, 2^255)`, the domain above that this is verified on. Individual + /// limbs may exceed 51 bits: [`Self::mul`] can return a limb of exactly `2^51` (Wycheproof + /// X25519 tcId 115 and 116), which the carry chain in [`Self::reduce`] handles. Checked by a + /// `debug_assert` in debug builds; the [`NormalizedFieldElement25519`] typestate cannot enforce + /// it. pub(crate) const fn normalize(&self) -> NormalizedFieldElement25519 { + // Carry the limbs through without folding: the value is in [0, 2^255) exactly when the + // carried top limb is in [0, 2^51). + let [x0, x1, x2, x3, x4] = self.0; + debug_assert!( + { + let c = ((((x0 >> 51) + x1) >> 51) + x2) >> 51; + let top = ((c + x3) >> 51) + x4; + 0 <= top && top <= M51 + }, + "normalize: value out of [0, 2^255)" + ); + let y = (self.0[4] >> (51 - 1)) & 1; NormalizedFieldElement25519(self.reduce(y).reduce(-y)) } @@ -606,6 +621,28 @@ impl FieldElement25519 { pub(crate) struct NormalizedFieldElement25519(FieldElement25519); impl NormalizedFieldElement25519 { + /// Debug-only check of the invariant this type carries: every limb in `[0, 2^51)` and the + /// value below p. Every method calls it first; it compiles to nothing in release builds. + /// + /// p's limbs are `[2^51 - 19, 2^51 - 1, 2^51 - 1, 2^51 - 1, 2^51 - 1]`, so a value with every + /// limb in range is `>= p` exactly when limbs 1..4 are all `2^51 - 1` and limb 0 is at least + /// `2^51 - 19`. + /// + /// Branches on the limbs, so it is not constant time. That is acceptable only because it is + /// compiled out of release builds. + const fn debug_check_range(&self) { + let [x0, x1, x2, x3, x4] = self.0.0; + debug_assert!(0 <= x0 && x0 <= M51, "limb 0 out of [0, 2^51)"); + debug_assert!(0 <= x1 && x1 <= M51, "limb 1 out of [0, 2^51)"); + debug_assert!(0 <= x2 && x2 <= M51, "limb 2 out of [0, 2^51)"); + debug_assert!(0 <= x3 && x3 <= M51, "limb 3 out of [0, 2^51)"); + debug_assert!(0 <= x4 && x4 <= M51, "limb 4 out of [0, 2^51)"); + debug_assert!( + !(x1 == M51 && x2 == M51 && x3 == M51 && x4 == M51 && x0 >= M51 - 18), + "value >= p" + ); + } + // /// Constant-time equality of two field elements. // /// // /// Compares representations, not residue classes, so it is only correct on the normalized @@ -613,6 +650,8 @@ impl NormalizedFieldElement25519 { // /// // // TODO -- add back once we get to implementing Ed25519 // pub(crate) const fn are_equal(lhs: &Self, rhs: &Self) -> Condition { + // lhs.debug_check_range(); + // rhs.debug_check_range(); // let [x0, x1, x2, x3, x4] = lhs.0.0; // let [y0, y1, y2, y3, y4] = rhs.0.0; // Condition::::is_zero((x0 ^ y0) | (x1 ^ y1) | (x2 ^ y2) | (x3 ^ y3) | (x4 ^ y4)) @@ -628,6 +667,7 @@ impl NormalizedFieldElement25519 { /// Input: self; a 32-byte output buffer. /// Output: The value written to `bytes` as a 255-bit little-endian integer. pub(crate) fn encode_255(&self, bytes: &mut [u8; 32]) { + self.debug_check_range(); let [w0, w1, w2, w3] = self.encode64_255(); let (a, b, c, d) = (w0.to_le_bytes(), w1.to_le_bytes(), w2.to_le_bytes(), w3.to_le_bytes()); // Each word fills a fixed 8-byte window, least significant word first. Every index is a @@ -655,6 +695,7 @@ impl NormalizedFieldElement25519 { /// Input: self. /// Output: The value as four little-endian 64-bit words, least significant word first. const fn encode64_255(&self) -> [u64; 4] { + self.debug_check_range(); let [x0, x1, x2, x3, x4] = self.0.0; let z0 = (x0 | x1 << 51) as u64; let z1 = (x1 >> 13 | x2 << 38) as u64; @@ -670,6 +711,7 @@ impl NormalizedFieldElement25519 { // /// // // TODO -- add back once we get to implementing Ed25519 // pub(crate) const fn is_one(&self) -> Condition { + // self.debug_check_range(); // let [x0, x1, x2, x3, x4] = self.0.0; // let x0 = x0 ^ 1; // Condition::::is_zero(x0 | x1 | x2 | x3 | x4) @@ -682,6 +724,7 @@ impl NormalizedFieldElement25519 { // /// // // TODO -- add back once we get to implementing Ed25519 // pub(crate) const fn is_zero(&self) -> Condition { + // self.debug_check_range(); // let [x0, x1, x2, x3, x4] = self.0.0; // Condition::::is_zero(x0 | x1 | x2 | x3 | x4) // } @@ -693,6 +736,7 @@ impl NormalizedFieldElement25519 { // /// a wrong answer, not merely a non-canonical one. // // TODO -- add back once we get to implementing Ed25519 // pub(crate) const fn is_odd(&self) -> u8 { + // self.debug_check_range(); // (self.0.0[0] & 1) as u8 // } } @@ -726,6 +770,46 @@ mod tests { assert_eq!(FieldElement25519::decode_255(&bit_255_set).0, KNOWN_LIMBS); } + /// p - 1 = 2^255 - 20, the largest canonical value, passes the range check. + #[test] + fn debug_check_range_accepts_p_minus_1() { + NormalizedFieldElement25519(FieldElement25519([M51 - 19, M51, M51, M51, M51])) + .debug_check_range(); + } + + /// p itself is not canonical; the range check must reject it. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "value >= p")] + fn debug_check_range_rejects_p() { + NormalizedFieldElement25519(FieldElement25519([M51 - 18, M51, M51, M51, M51])) + .debug_check_range(); + } + + /// A limb at 2^51 is out of range even though the value is below p. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "limb 0 out of [0, 2^51)")] + fn debug_check_range_rejects_wide_limb() { + NormalizedFieldElement25519(FieldElement25519([M51 + 1, 0, 0, 0, 0])).debug_check_range(); + } + + /// A value of `2^255` or more is outside the domain normalize is verified on. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "normalize: value out of [0, 2^255)")] + fn normalize_rejects_value_2_pow_255() { + let _ = FieldElement25519([0, 0, 0, 0, M51 + 1]).normalize(); + } + + /// The shape of input Wycheproof X25519 tcId 115 produces: limb 1 is exactly `2^51`, but the + /// value is `2^51 * 2^51 + (2^51 - 1) * (2^102 + 2^153) = 2^204`, well inside the domain. + #[test] + fn normalize_accepts_limb_of_2_pow_51() { + let n = FieldElement25519([0, 1 << 51, M51, M51, 0]).normalize(); + assert_eq!(n.0.0, [0, 0, 0, 0, 1]); + } + #[test] fn encode_255_known_value() { // Start from a non-zero buffer so a partial write cannot pass by accident. diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index d5925b44f..6e92420a5 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -163,9 +163,3 @@ fn check_vector(sk: &str, su: &str, se: &str, text: &str) { calculate_agreement(&k, &u, &mut r); check_value(&r, text, se); } - -// fn decode_hex(hex: &str) -> [u8; N] { -// let mut result = [0; N]; -// hex::decode_to_slice(hex, &mut result).unwrap(); -// result -// } From d806822353d71b50ea72999144fc1e68afdb1b56 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 22:48:45 -0500 Subject: [PATCH 12/26] Progress on x25519 --- crypto/ed/src/curve25519.rs | 18 ++++++++++++++++++ crypto/ed/tests/x25519_tests.rs | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index f4fca7ff6..054852700 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -810,6 +810,24 @@ mod tests { assert_eq!(n.0.0, [0, 0, 0, 0, 1]); } + /// The values either side of p, where the `y` adjustment in normalize decides the answer. Not + /// reachable through the public API: X25519 only normalizes ladder outputs, which land in + /// `[p, 2^255)` with negligible probability. + #[test] + fn normalize_edge_values() { + // p - 1 = 2^255 - 20: already canonical, unchanged. + let n = FieldElement25519([M51 - 19, M51, M51, M51, M51]).normalize(); + assert_eq!(n.0.0, [M51 - 19, M51, M51, M51, M51]); + + // p = 2^255 - 19: reduces to 0. + let n = FieldElement25519([M51 - 18, M51, M51, M51, M51]).normalize(); + assert_eq!(n.0.0, [0, 0, 0, 0, 0]); + + // 2^255 - 1, the largest non-canonical value: reduces to 18. + let n = FieldElement25519([M51, M51, M51, M51, M51]).normalize(); + assert_eq!(n.0.0, [18, 0, 0, 0, 0]); + } + #[test] fn encode_255_known_value() { // Start from a non-zero buffer so a partial write cannot pass by accident. diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 6e92420a5..0ca5e0cf2 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -101,6 +101,20 @@ fn rfc7748_section_6_1() { check_value(&bob_k, "Section 6.1 K (Bob)", k); } +/// u = 0 is a point of low order, so X25519(k, 0) is the all-zero value for every scalar, the case +/// RFC 7748 Section 6.1 describes checking for. `calculate_agreement` must write the zeros and +/// return `false`. +#[test] +fn low_order_point_gives_all_zero_shared_secret() { + let k = X25519Scalar::from_bytes(&decode_32( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + )); + // Start from a non-zero buffer so a missing write cannot pass by accident. + let mut out = [0xFF_u8; POINT_SIZE]; + assert!(!calculate_agreement(&k, &X25519Point::from_bytes([0; POINT_SIZE]), &mut out)); + assert_eq!(out, [0; POINT_SIZE]); +} + fn decode_32(s: &str) -> [u8; 32] { hex::decode(s).unwrap().try_into().unwrap() } From acfbad738ff1eda9e62b629955641f40b5645e06 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Thu, 29 Oct 2026 22:54:03 -0500 Subject: [PATCH 13/26] cargo mutants pass for x25519 --- crypto/ed/src/curve25519.rs | 34 ++++++++++++++-------------------- crypto/ed/src/x25519.rs | 12 ++---------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/crypto/ed/src/curve25519.rs b/crypto/ed/src/curve25519.rs index 054852700..d24324519 100644 --- a/crypto/ed/src/curve25519.rs +++ b/crypto/ed/src/curve25519.rs @@ -341,23 +341,18 @@ impl FieldElement25519 { /// /// Idempotent. /// - /// Two [`Self::reduce`] passes with `y` and then `-y`, where `y` is bit 254 of the value (bit 50 - /// of limb `x4`, since `x4` sits at weight `2^204`). Each pass adds `19 * y` at the bottom - /// without a matching removal at the top, so neither pass alone is congruent mod p -- but the - /// `+19y` and `-19y` cancel exactly, leaving `value - (c + c') * p` for the two top-carry folds - /// `c`, `c'`. - /// - /// Adding `19 * y` in the first pass and removing it in the second makes the top carry fire - /// exactly when the value is `>= p`, since `v >= p` if and only if `v + 19 >= 2^255`. Only values - /// `>= 2^254` can be `>= p`, which is why `y` is bit 254. - /// - /// This is what implements Section 5 of RFC7748's "Implementations MUST accept non-canonical - /// values and process them as if they had been reduced modulo the field prime. The non-canonical - /// values are 2^255 - 19 through 2^255 - 1". - /// This `reduce(y).reduce(-y)` is exact on all of `[0, 2^255)`, which is the entire range - /// [`Self::decode_255`] can produce. It first fails at `2^255`, outside the input domain. - /// - /// Requires the value in `[0, 2^255)`, the domain above that this is verified on. Individual + /// Two [`Self::reduce`] passes, `reduce(1)` then `reduce(-1)`: the first adds 19 at the bottom + /// and the second removes it, with no matching change at the top. Neither pass alone is + /// congruent mod p, but the `+19` and `-19` cancel exactly, leaving `value - (c + c') * p` for + /// the two top-carry folds `c`, `c'`. + /// + /// The `+19` makes the first pass's top carry fire exactly when the value is `>= p`, since + /// `v >= p` if and only if `v + 19 >= 2^255`. This is what implements Section 5 of RFC7748's + /// "Implementations MUST accept non-canonical values and process them as if they had been + /// reduced modulo the field prime. The non-canonical values are 2^255 - 19 through 2^255 - 1". + /// The `normalize_edge_values` unit test pins `p - 1`, `p` and `2^255 - 1`. + /// + /// Requires the value in `[0, 2^255)`, which is what [`Self::decode_255`] produces. Individual /// limbs may exceed 51 bits: [`Self::mul`] can return a limb of exactly `2^51` (Wycheproof /// X25519 tcId 115 and 116), which the carry chain in [`Self::reduce`] handles. Checked by a /// `debug_assert` in debug builds; the [`NormalizedFieldElement25519`] typestate cannot enforce @@ -375,8 +370,7 @@ impl FieldElement25519 { "normalize: value out of [0, 2^255)" ); - let y = (self.0[4] >> (51 - 1)) & 1; - NormalizedFieldElement25519(self.reduce(y).reduce(-y)) + NormalizedFieldElement25519(self.reduce(1).reduce(-1)) } /// The multiplicative identity, 1, in canonical form. @@ -810,7 +804,7 @@ mod tests { assert_eq!(n.0.0, [0, 0, 0, 0, 1]); } - /// The values either side of p, where the `y` adjustment in normalize decides the answer. Not + /// The values either side of p, where normalize's `+19` / `-19` passes decide the answer. Not /// reachable through the public API: X25519 only normalizes ladder outputs, which land in /// `[p, 2^255)` with negligible probability. #[test] diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index f0bae6c94..ac8ed8a36 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -1,4 +1,4 @@ -use bouncycastle_utils::ct::Condition; +use bouncycastle_utils::ct::{Condition, ct_eq_zero_bytes}; use bouncycastle_utils::secret::Secret; use crate::curve25519::FieldElement25519; @@ -68,19 +68,11 @@ const C_A24: u32 = (C_A + 2) / 4; // TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait -fn are_all_zeros(r: &[u8]) -> bool { - let mut bits = 0; - for i in r.iter() { - bits |= i; - } - bits == 0 -} - // TODO -- remove pub visibility once I have the traits impl'd // TODO -- the shared secret is still written to bare bytes; Phase 2 decides its type (KeyMaterial). pub fn calculate_agreement(k: &X25519Scalar, u: &X25519Point, r: &mut [u8; POINT_SIZE]) -> bool { scalar_mult(k, u, r); - !are_all_zeros(&r[..POINT_SIZE]) + !ct_eq_zero_bytes(r) } /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: From 6df5b79d01c2aa2f140da5fb18ef33251ff10040 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 00:30:53 -0500 Subject: [PATCH 14/26] Implemented core::traits::KeyAgreement for X25519. And started writing crate docs --- crypto/core/src/errors.rs | 22 ++ crypto/core/src/traits.rs | 9 +- crypto/ed/Cargo.toml | 2 + crypto/ed/ed_todo.md | 32 +-- crypto/ed/src/lib.rs | 5 + crypto/ed/src/x25519.rs | 309 +++++++++++++++++++++++---- crypto/ed/tests/x25519_key_tests.rs | 156 ++++++++++++++ crypto/ed/tests/x25519_tests.rs | 107 ++++------ crypto/ed/tests/x25519_wycheproof.rs | 32 ++- 9 files changed, 536 insertions(+), 138 deletions(-) create mode 100644 crypto/ed/tests/x25519_key_tests.rs diff --git a/crypto/core/src/errors.rs b/crypto/core/src/errors.rs index 211cb9220..c2ca37820 100644 --- a/crypto/core/src/errors.rs +++ b/crypto/core/src/errors.rs @@ -83,6 +83,22 @@ pub enum KEMError { RNGError(RNGError), } +/// +#[derive(Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum KeyAgreementError { + /// + GenericError(&'static str), + /// + ConsistencyCheckFailed(&'static str), + /// The peer's public key is unusable for key agreement, for example a point of small order. + InvalidPublicKey(&'static str), + /// + KeyGenError(&'static str), + /// + RNGError(RNGError), +} + /// #[derive(Debug, PartialEq, Eq)] #[non_exhaustive] @@ -258,6 +274,12 @@ impl From for KEMError { } } +impl From for KeyAgreementError { + fn from(e: RNGError) -> KeyAgreementError { + Self::RNGError(e) + } +} + impl From for MACError { fn from(e: KeyMaterialError) -> MACError { Self::KeyMaterialError(e) diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 01c4d487d..c8be367bc 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1088,9 +1088,14 @@ pub trait KEMPublicKey: /// The core function is `key_agreement(&pk, &sk) -> OutputType` which acts on one public key and one private key. /// The return type of `key_agreement` will depend on the specific algorithm -- often it will be a `[u8]`, but it /// may be a struct, such as a public key. -pub trait KeyAgreement { +pub trait KeyAgreement: Sized { + /// The output type of [`KeyAgreement::key_agreement`]. + /// It may be any type that makes sense, but it must impl `AsRef<[u8]>` so that it can be, for + /// example, trivially used as input to a KDF or hash function. + type SharedSecretType: AsRef<[u8]>; + /// Perform a key agreement - fn key_agreement(pk: &PK, sk: SK) -> OutputType; + fn key_agreement(pk: &PK, sk: SK) -> Result; } /// A public key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "pk". diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index d44b788b2..11bd2affe 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -5,9 +5,11 @@ version.workspace = true [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 bouncycastle-rng.workspace = true +bouncycastle-sha2.workspace = true diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index cd6cd3f0a..8f56cb7b1 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -42,7 +42,7 @@ Within each, the priority order is: - [x] Identify `C_A` (486662) and `C_A24` and document them against RFC 7748. - [x] Remove the dead code: `precompute()` is gone, and `P32`, `P64` and the methods X25519 does not use are commented out; the crate builds with no warnings. -- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). +- [x] `cargo mutants -p bouncycastle-ed` (without Wycheproof). ## Phase 2: Traits, Bells & Whistles @@ -52,23 +52,25 @@ Within each, the priority order is: const generics); - [ ] the output type, wrapped in `KeyMaterial` (the shared secret is sensitive); - [ ] an error type. -- [x] Add X25519 key types: `X25519Scalar` (`Secret`-backed, zeroized, redacted `Debug`) and - `X25519Point`, used by `calculate_agreement` and `generate_public_key`. +- [x] Add X25519 key types: `X25519PrivateKey` (`Secret`-backed, zeroized, redacted `Debug`) and + `X25519PublicKey`, used by `X25519::key_agreement` and `X25519PrivateKey::pk`. +- [x] Add `struct X25519` (`Algorithm`), and re-export it and the key types at the crate root. - [ ] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. -- [ ] Re-enable the `agreement` round-trip test. It currently calls a non-existent - `generate_private_key_rng`. +- [x] Re-enable the `agreement` round-trip test, now in `x25519_key_tests.rs` on `X25519::keygen`. - [ ] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from X25519. -- [ ] Decide what happens on an all-zero shared secret (low-order public key). Today - `calculate_agreement` outputs zeros and returns `false`; Wycheproof marks these vectors - "acceptable". Reject, or pass through? This maybe requires some research to see if this condition can be detected on - the public inputs before peforming the private key operations (would be better for constan-time concerns). +- [x] Decide what happens on an all-zero shared secret (low-order public key): `key_agreement` + rejects it with `KeyAgreementError::InvalidPublicKey`. RFC 7748 §6.1 makes the check a MAY; + RFC 8446 §7.4.2 and RFC 9180 §7.1.4 make it a MUST. + - [ ] Open: detect a small-order public key before the private-key operation, rather than + from the output. - [ ] A `KEM` impl over X25519 (DHKEM-style). Mentioned in an `x25519.rs` TODO; decide if it belongs here. -- [ ] Keygen: - - [ ] random keygen from an `RNG`; - - [ ] deterministic keygen from a supplied 32-byte seed; - - [ ] public-from-private derivation and consistency-check. +- [x] Keygen, mirroring ML-KEM, on `X25519` with `KeyAgreementError` (added to `core`): + - [x] `keygen` (OS-seeded `HashDRBG_SHA512`) and `keygen_from_rng`; + - [x] `keygen_from_seed`, from a 32-byte `KeyMaterial256`; + - [x] `keypair_consistency_check`. No `keygen_from_seed_and_encoded`: the seed *is* the + private key encoding, so there is nothing to compare. - [ ] Fast fixed-base `scalar_mult_base`. Today it runs the generic ladder with u = 9; there is a commented-out Edwards-form path. - [ ] Wire into the factory (a `KeyAgreementFactory`, if that fits the factory pattern) and add a @@ -78,8 +80,7 @@ Within each, the priority order is: ## Phase 3: Docs and ergonomics - [x] `FieldElement25519` and its methods are `pub(crate)`. -- [ ] Make `calculate_agreement` (and the other `x25519.rs` free functions) `pub(crate)` once the - Phase 2 trait API replaces them; the tests in `tests/` call them today. +- [x] Remove `calculate_agreement`; the tests and Wycheproof suite use `X25519::key_agreement`. - [x] `FieldElement25519` cleanups: - [x] `[Secret; 5]`? Block `Debug`? Prune the derive list. - [x] In-place methods (`*_mut`, `cmov`, `cnegate`) return `()`, not `&mut Self`. @@ -92,6 +93,7 @@ Within each, the priority order is: - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md. - [ ] Is XDH in a NIST document? (`lib.rs` `# Standards`.) - [ ] Benches: criterion, plus a `mem_usage_benches` harness and a `# Memory Usage` docs section. +- [ ] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) --- diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index 4c05c73cc..2c9ff647d 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -11,6 +11,10 @@ //! //! TODO -- is XDH specified in a NIST doc? //! +//! # Usage Examples +//! +//! See the [`x25519`] module for X25519 key generation and an Alice-and-Bob key agreement that +//! derives a symmetric key from the shared secret. #![no_std] #![forbid(unsafe_code)] @@ -18,6 +22,7 @@ // TODO -- #![forbid(missing_docs)] // Add back at the end +// TODO -- why is this needed? extern crate alloc; mod curve25519; diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index ac8ed8a36..0dc27994c 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -1,3 +1,91 @@ +//! # Usage Examples +//! +//! ## Generating keys +//! +//! ```rust +//! use bouncycastle_ed::x25519::X25519; +//! +//! let (pk, sk) = X25519::keygen().unwrap(); +//! ``` +//! That will use the library's default OS-backed RNG. To supply your own randomness, use +//! [`X25519::keygen_from_rng`], or [`X25519::keygen_from_seed`] with a 32-byte seed, which is also +//! how an existing private key is imported. +//! +//! ## X25519 key agreement +//! +//! Alice and Bob each generate a key pair and send each other their public keys. Each then combines +//! their own private key with the other's public key in +//! [`X25519::key_agreement`](X25519#method.key_agreement), and both arrive +//! at the same shared secret `K`. +//! +//! `K` is the 32-byte representation of the U-coordinate of a point on curve25519 -- **its bytes are +//! not uniformly random**, so it should not be used as a key directly: put it through a KDF, +//! along with both public keys (RFC 7748 §6.1). Here that is HKDF-SHA256, with `K` as the input key +//! material and `Alice's public key || Bob's public key` as the context. Both parties must put the +//! public keys in the same order. +//! +//! ```rust +//! use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +//! use bouncycastle_core::security_strength::SecurityStrength; +//! use bouncycastle_core::traits::{KDF, KeyAgreement}; +//! use bouncycastle_ed::x25519::{X25519, X25519PublicKey}; +//! use bouncycastle_sha2::hkdf::HKDF_SHA256; +//! +//! // Alice and Bob each generate a key pair, and exchange public keys. +//! let (alice_pk, alice_sk) = X25519::keygen().unwrap(); +//! let (bob_pk, bob_sk) = X25519::keygen().unwrap(); +//! +//! // Each computes the shared secret K from their own private key and the other's public key. +//! // An error here means the other party's public key is unusable; abort the exchange. +//! let alice_k = X25519::key_agreement(&bob_pk, alice_sk).expect("Bad public key"); +//! let bob_k = X25519::key_agreement(&alice_pk, bob_sk).expect("Bad public key"); +//! +//! // Both derive the symmetric key from K, with both public keys as context, +//! // as recommended in RFC 7748 §6.1. +//! fn derive_key_rfc7748( +//! k: &X25519PublicKey, +//! alice_pk: &X25519PublicKey, +//! bob_pk: &X25519PublicKey) -> KeyMaterial256 { +//! let mut ikm = +//! KeyMaterial256::from_bytes_as_type(k.as_ref(), KeyType::CryptographicRandom).unwrap(); +//! // X25519 provides 128-bit security, not the 256 bits a 32-byte key would imply. +//! // This is related to the fact that an X25519PublicKey encodes to 32 bytes, +//! // but does not contain 32 bytes of entropy. +//! ikm.set_security_strength(SecurityStrength::_128bit).unwrap(); +//! +//! let mut context = [0u8; 64]; +//! context[..32].copy_from_slice(alice_pk.as_ref()); +//! context[32..].copy_from_slice(bob_pk.as_ref()); +//! +//! let mut key = KeyMaterial256::new(); +//! HKDF_SHA256::new().derive_key_out(&ikm, &context, &mut key).unwrap(); +//! key +//! } +//! +//! // Each derives `key` independently. +//! // Note that they have to agree on whose key goes in first; +//! // if Alice does `(alice_pk, bob_pk)` and bob does `(bob_pk, alice_pk)` then they will +//! // arrive at different output keys. +//! // Typically this will be specified unambiguously by the protocol, such as +//! // (sender_pk, recipient_pk). +//! let alice_key = derive_key_rfc7748(&alice_k, &alice_pk, &bob_pk); +//! let bob_key = derive_key_rfc7748(&bob_k, &alice_pk, &bob_pk); +//! +//! // Alice and Bob now hold the same 32-byte, 128-bit-strength symmetric KeyMaterial. +//! assert!(alice_key.equals(&bob_key)); +//! assert_eq!(alice_key.key_len(), 32); +//! assert_eq!(alice_key.key_type(), KeyType::CryptographicRandom); +//! assert_eq!(alice_key.security_strength(), SecurityStrength::_128bit); +//! ``` +//! + +use bouncycastle_core::errors::{KeyAgreementError, RNGError}; +use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +use bouncycastle_core::security_strength::SecurityStrength; +use bouncycastle_core::traits::{ + Algorithm, KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey, RNG, +}; +use bouncycastle_rng::HashDRBG_SHA512; use bouncycastle_utils::ct::{Condition, ct_eq_zero_bytes}; use bouncycastle_utils::secret::Secret; @@ -11,32 +99,170 @@ use crate::curve25519::FieldElement25519; pub const POINT_SIZE: usize = 32; pub const SCALAR_SIZE: usize = 32; +/// String name of the X25519 algorithm. +pub const X25519_NAME: &str = "X25519"; + +/// The X25519 key agreement algorithm of RFC 7748 §5. +pub struct X25519; + +impl Algorithm for X25519 { + const ALG_NAME: &'static str = X25519_NAME; + // RFC 7748 §7: "the security level ... of curve25519 is slightly under the standard 128-bit + // level", which it argues is acceptable; it is classed here at the conventional 128 bits. + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl X25519 { + /// Generates a fresh key pair using the library's default RNG, seeded from the OS. + /// In environments where the default OS based RNG is not available, use instead + /// [`X25519::keygen_from_rng`] and explicitly provide an [`RNG`] implementation, or use + /// [`X25519::keygen_from_seed`] and provide the private key seed directly. + pub fn keygen() -> Result<(X25519PublicKey, X25519PrivateKey), KeyAgreementError> { + let mut os_rng = HashDRBG_SHA512::new_from_os(); + Self::keygen_from_rng(&mut os_rng) + } + + /// Generates a key pair using the provided RNG implementation. + pub fn keygen_from_rng( + rng: &mut dyn RNG, + ) -> Result<(X25519PublicKey, X25519PrivateKey), KeyAgreementError> { + if rng.security_strength() < Self::MAX_SECURITY_STRENGTH { + return Err(RNGError::SecurityStrengthInsufficientForAlgorithm)?; + } + let mut seed = KeyMaterial256::new(); + rng.fill_keymaterial_out(&mut seed)?; + Self::keygen_from_seed(&seed) + } + + /// Generates a key pair from a 32-byte seed, which must have [`KeyType::Seed`] or + /// [`KeyType::CryptographicRandom`] and at least [`SecurityStrength::_128bit`]. + /// + /// RFC 7748 §6.1: "Alice generates 32 random bytes in `a[0]` to `a[31]` and transmits + /// K_A = X25519(a, 9) to Bob". The seed is used as the private key unchanged, so this is also + /// how to import an existing private key along with its public key. + pub fn keygen_from_seed( + seed: &KeyMaterial256, + ) -> Result<(X25519PublicKey, X25519PrivateKey), KeyAgreementError> { + if !(seed.key_type() == KeyType::Seed || seed.key_type() == KeyType::CryptographicRandom) + || seed.key_len() != SCALAR_SIZE + { + return Err(KeyAgreementError::KeyGenError( + "Seed must be 32 bytes and KeyType::Seed or KeyType::CryptographicRandom.", + )); + } + + if seed.security_strength() < Self::MAX_SECURITY_STRENGTH { + return Err(KeyAgreementError::KeyGenError( + "Seed SecurityStrength must match algorithm security strength", + )); + } + + let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + // Cannot panic: the length was checked above. + k.copy_from_slice(seed.ref_to_bytes()); + let sk = X25519PrivateKey(k); + + Ok((sk.pk(), sk)) + } + + /// Checks that `pk` is the public key of `sk`, by deriving it from `sk` and comparing. + /// + /// Returns either `()` or [`KeyAgreementError::ConsistencyCheckFailed`]. + pub fn keypair_consistency_check( + pk: &X25519PublicKey, + sk: &X25519PrivateKey, + ) -> Result<(), KeyAgreementError> { + sk.consistency_check(pk) + } +} + +impl KeyAgreement for X25519 { + type SharedSecretType = X25519PublicKey; + + /// The X25519 Diffie-Hellman of RFC 7748 §6.1: computes the shared secret + /// `K = X25519(sk, pk)`, the u-coordinate of the scalar multiple of the peer's public key by + /// our private key, as its canonical 32-byte little-endian encoding. + /// + /// Alice computes `X25519(a, K_B)` and Bob `X25519(b, K_A)`, and §6.1 shows both equal + /// `X25519(a, X25519(b, 9)) = X25519(b, X25519(a, 9))`. §6.1 also says to then "use a + /// key-derivation function that includes K, K_A, and K_B to derive a symmetric key": `K` is + /// not uniformly random, so it should not be used as a key directly. + /// + /// Any 32 bytes are accepted as `pk`: per RFC 7748 §5, bit 255 is ignored and values `>= p` + /// are processed as if reduced mod p. + /// + /// # Errors + /// + /// Returns [`KeyAgreementError::InvalidPublicKey`] if `K` is all zeros. RFC 7748 §6.1: "The + /// check for the all-zero value results from the fact that the X25519 function produces that + /// value if it operates on an input corresponding to a point with small order". Such a point + /// "will eliminate any contribution from the other party's private key" (§7), so `K` is the + /// same for every private key and is known to whoever chose `pk`. + /// + /// RFC 7748 makes the check optional: both parties "MAY check, without leaking extra + /// information about the value of K, whether K is the all-zero value and abort if so". This + /// implementation always performs it, as the protocols built on X25519 require: + /// * RFC 8446 (TLS 1.3) §7.4.2: "Implementations MUST check whether the computed + /// Diffie-Hellman shared secret is the all-zero value and abort if so". + /// * RFC 9180 (HPKE) §7.1.4: "recipients MUST check whether the Diffie-Hellman shared secret + /// is the all-zero value and abort if so". + /// + /// The check is constant time, ORing all the bytes together as §6.1 suggests, so the only + /// information it reveals about `K` is whether it is zero. + fn key_agreement( + pk: &X25519PublicKey, + sk: X25519PrivateKey, + ) -> Result { + let r = scalar_mult(&sk, pk); + if r.is_zero() { + return Err(KeyAgreementError::InvalidPublicKey( + "Public key has small order: the shared secret is all zeros", + )); + } + Ok(r) + } +} + /// An X25519 private key: a 32-byte scalar, as defined in Section 5 of RFC7748. /// /// Any 32 bytes are a valid scalar. The fixed bits `decodeScalar25519` sets and clears are applied /// each time the scalar is used, not on construction, so the stored bytes are exactly the ones the /// caller supplied. Held in a [`Secret`], so it is zeroized on drop and redacted from `Debug`. -pub struct X25519Scalar(Secret<[u8; SCALAR_SIZE]>); +pub struct X25519PrivateKey(Secret<[u8; SCALAR_SIZE]>); + +impl X25519PrivateKey { + /// Computes the public key, `X25519(k, 9)` (RFC 7748 §6.1). + /// + /// The public key is not stored with the private key, so each call runs a full scalar + /// multiplication. + pub fn pk(&self) -> X25519PublicKey { + scalar_mult_base(self) + } -impl X25519Scalar { - /// Copies 32 bytes into a new scalar. The copy lives in zeroizing storage; the caller's buffer - /// is not cleared. - pub fn from_bytes(bytes: &[u8; SCALAR_SIZE]) -> Self { - let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); - *k = *bytes; - Self(k) + /// Checks that `pk` is this private key's public key, by computing it with [`Self::pk`] and + /// comparing. + /// + /// Returns either `()` or [`KeyAgreementError::ConsistencyCheckFailed`]. + pub fn consistency_check(&self, pk: &X25519PublicKey) -> Result<(), KeyAgreementError> { + if self.pk() == *pk { + Ok(()) + } else { + Err(KeyAgreementError::ConsistencyCheckFailed("Public key does not match private key")) + } } } +impl KeyAgreementPrivateKey for X25519PrivateKey {} + /// An X25519 public value: the u-coordinate of a point on curve25519, as 32 little-endian bytes /// (Section 5 of RFC7748). /// /// Any 32 bytes are accepted: Section 5 requires bit 255 to be ignored and values `>= p` to be /// processed as if reduced mod p. Public data, so it is `Copy` and its `Debug` shows the bytes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct X25519Point([u8; POINT_SIZE]); +pub struct X25519PublicKey([u8; POINT_SIZE]); -impl X25519Point { +impl X25519PublicKey { /// Wraps 32 bytes as a u-coordinate. pub const fn from_bytes(bytes: [u8; POINT_SIZE]) -> Self { Self(bytes) @@ -46,13 +272,30 @@ impl X25519Point { pub const fn as_bytes(&self) -> &[u8; POINT_SIZE] { &self.0 } + + /// Constant-time test for all 32 bytes being zero. + /// + /// This tests the encoding, not the value: it equals "u = 0" only for canonical encodings, + /// such as [`scalar_mult`]'s output. A non-canonical encoding of 0 (`p`, or bit 255 set) + /// returns `false`. + fn is_zero(&self) -> bool { + ct_eq_zero_bytes(&self.0) + } +} + +impl KeyAgreementPublicKey for X25519PublicKey {} + +impl AsRef<[u8]> for X25519PublicKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } } /// The base point of curve25519, `U(P) = 9` (Section 4.1 of RFC7748). -const BASE_POINT: X25519Point = { +const BASE_POINT: X25519PublicKey = { let mut u = [0_u8; POINT_SIZE]; u[0] = 9; - X25519Point(u) + X25519PublicKey(u) }; /// The coefficient A of curve25519, `v^2 = u^3 + A*u^2 + u` (RFC 7748 §4.1). @@ -66,15 +309,6 @@ const C_A: u32 = 486662; /// [`ladder_double`] both use that form. const C_A24: u32 = (C_A + 2) / 4; -// TODO -- impl KeyAgreement trait (will need to add to traits.rs), and a KEM trait - -// TODO -- remove pub visibility once I have the traits impl'd -// TODO -- the shared secret is still written to bare bytes; Phase 2 decides its type (KeyMaterial). -pub fn calculate_agreement(k: &X25519Scalar, u: &X25519Point, r: &mut [u8; POINT_SIZE]) -> bool { - scalar_mult(k, u, r); - !ct_eq_zero_bytes(r) -} - /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: /// /// ```text @@ -106,7 +340,7 @@ pub fn calculate_agreement(k: &X25519Scalar, u: &X25519Point, r: &mut [u8; POINT /// /// Input: A 32-byte little-endian scalar. /// Output: The decoded scalar, written to `n` as eight little-endian 32-bit words. -fn decode_scalar_25519(k: &X25519Scalar, n: &mut [u32; 8]) { +fn decode_scalar_25519(k: &X25519PrivateKey, n: &mut [u32; 8]) { let (words, _) = k.0.as_chunks::<4>(); // 32 bytes: exactly 8 chunks, no remainder for (n_i, w) in n.iter_mut().zip(words) { *n_i = u32::from_le_bytes(*w); @@ -141,7 +375,7 @@ fn decode_scalar_25519(k: &X25519Scalar, n: &mut [u32; 8]) { /// /// Input: A 32-byte little-endian u-coordinate. /// Output: The u-coordinate as a [`FieldElement25519`], not normalized. -fn decode_u_coordinate(u: &X25519Point) -> FieldElement25519 { +fn decode_u_coordinate(u: &X25519PublicKey) -> FieldElement25519 { FieldElement25519::decode_255(&u.0) } @@ -157,22 +391,6 @@ fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_SIZE]) { u.normalize().encode_255(r); } -// // TODO make this match the style of MLDSA keygen -// pub fn generate_private_key(random: &mut dyn RngCore, k: &mut [u8; SCALAR_SIZE]) { -// random.fill_bytes(&mut k[..]); -// -// k[0] &= 0xF8; -// k[SCALAR_SIZE - 1] &= 0x7F; -// k[SCALAR_SIZE - 1] |= 0x40; -// } - -// TODO make this match the style of MLDSA keygen -pub fn generate_public_key(k: &X25519Scalar) -> X25519Point { - let mut u = [0_u8; POINT_SIZE]; - scalar_mult_base(k, &mut u); - X25519Point(u) -} - /// Doubles one point, held projectively as the pair `(x : z)` representing `u = x / z` /// Updated in place. /// @@ -213,9 +431,8 @@ fn ladder_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { /// # Inputs /// * `k`, a 32-byte scalar /// * `u`, a 32-byte little-endian u-coordinate -/// * `out`, a 32-byte output buffer. -/// Output: The canonical little-endian encoding of the u-coordinate of `k * u`, written to `out`. -fn scalar_mult(k: &X25519Scalar, u: &X25519Point, out: &mut [u8; POINT_SIZE]) { +/// Output: The canonical little-endian encoding of the u-coordinate of `k * u`. +fn scalar_mult(k: &X25519PrivateKey, u: &X25519PublicKey) -> X25519PublicKey { // The decoded scalar is as secret as `k` itself. let mut n: Secret<[u32; 8]> = Secret::new(); decode_scalar_25519(k, &mut n); @@ -320,11 +537,13 @@ fn scalar_mult(k: &X25519Scalar, u: &X25519Point, out: &mut [u8; POINT_SIZE]) { } x2 = x2.mul(&z2.inv()); - encode_u_coordinate(&x2, out); + let mut out = [0_u8; POINT_SIZE]; + encode_u_coordinate(&x2, &mut out); + X25519PublicKey(out) } -fn scalar_mult_base(k: &X25519Scalar, r: &mut [u8; POINT_SIZE]) { +fn scalar_mult_base(k: &X25519PrivateKey) -> X25519PublicKey { // Equivalent (but much slower) -- since the BASE_POINT never changes, you can // precompute lookup tables for the multiples of BASE_POINT. TODO - scalar_mult(k, &BASE_POINT, r); + scalar_mult(k, &BASE_POINT) } diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs new file mode 100644 index 000000000..075c39e0f --- /dev/null +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -0,0 +1,156 @@ +use bouncycastle_core::errors::{KeyAgreementError, RNGError}; +use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +use bouncycastle_core::security_strength::SecurityStrength; +use bouncycastle_core::traits::KeyAgreement; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_ed::x25519::X25519; +use bouncycastle_hex as hex; + +/// RFC 7748 Section 6.1: Alice's private key a and public key X25519(a, 9). +const ALICE_SK: &str = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"; +const ALICE_PK: &str = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"; + +fn seed(hex_str: &str, key_type: KeyType) -> KeyMaterial256 { + KeyMaterial256::from_bytes_as_type(&hex::decode(hex_str).unwrap(), key_type).unwrap() +} + +#[test] +fn keygen_from_seed() { + for key_type in [KeyType::Seed, KeyType::CryptographicRandom] { + let (pk, sk) = X25519::keygen_from_seed(&seed(ALICE_SK, key_type)).unwrap(); + assert_eq!(pk.as_bytes().as_slice(), hex::decode(ALICE_PK).unwrap()); + X25519::keypair_consistency_check(&pk, &sk).unwrap(); + } +} + +#[test] +fn keygen_from_seed_rejects_bad_seeds() { + // Wrong key type. + match X25519::keygen_from_seed(&seed(ALICE_SK, KeyType::MACKey)) { + Err(KeyAgreementError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError"), + } + + // All zeros: KeyMaterial marks it KeyType::Zeroized. + match X25519::keygen_from_seed(&seed(&"00".repeat(32), KeyType::Seed)) { + Err(KeyAgreementError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError"), + } + + // Undersized. + let short = seed(&ALICE_SK[..62], KeyType::Seed); + assert_eq!(short.key_len(), 31); + match X25519::keygen_from_seed(&short) { + Err(KeyAgreementError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError"), + } + + // Security strength below the algorithm's, and exactly at it. + let mut weak = seed(ALICE_SK, KeyType::Seed); + weak.set_security_strength(SecurityStrength::_112bit).unwrap(); + match X25519::keygen_from_seed(&weak) { + Err(KeyAgreementError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError"), + } + let mut just_right = seed(ALICE_SK, KeyType::Seed); + just_right.set_security_strength(SecurityStrength::_128bit).unwrap(); + X25519::keygen_from_seed(&just_right).unwrap(); +} + +#[test] +fn keygen_from_rng() { + // The RNG output is used as the seed, so this must match keygen_from_seed. + let seed_bytes: [u8; 32] = hex::decode(ALICE_SK).unwrap().try_into().unwrap(); + let mut rng = FixedSeedRNG::new(seed_bytes); + let (pk, sk) = X25519::keygen_from_rng(&mut rng).unwrap(); + assert_eq!(pk.as_bytes().as_slice(), hex::decode(ALICE_PK).unwrap()); + X25519::keypair_consistency_check(&pk, &sk).unwrap(); + + // RNG too weak. + let mut rng = FixedSeedRNG::new(seed_bytes); + rng.set_security_strength(SecurityStrength::_112bit); + match X25519::keygen_from_rng(&mut rng) { + Err(KeyAgreementError::RNGError(RNGError::SecurityStrengthInsufficientForAlgorithm)) => { + /* good */ + } + _ => panic!("expected RNGError::SecurityStrengthInsufficientForAlgorithm"), + } + + // RNG just right. + let mut rng = FixedSeedRNG::new(seed_bytes); + rng.set_security_strength(SecurityStrength::_128bit); + X25519::keygen_from_rng(&mut rng).unwrap(); +} + +#[test] +fn keygen() { + let (pk1, sk1) = X25519::keygen().unwrap(); + let (pk2, sk2) = X25519::keygen().unwrap(); + assert_ne!(pk1, pk2); + X25519::keypair_consistency_check(&pk1, &sk1).unwrap(); + X25519::keypair_consistency_check(&pk2, &sk2).unwrap(); +} + +#[test] +fn keypair_consistency_check() { + let (pk1, sk1) = X25519::keygen().unwrap(); + let (pk2, sk2) = X25519::keygen().unwrap(); + X25519::keypair_consistency_check(&pk1, &sk1).unwrap(); + + match X25519::keypair_consistency_check(&pk1, &sk2) { + Err(KeyAgreementError::ConsistencyCheckFailed(_)) => { /* good */ } + _ => panic!("expected ConsistencyCheckFailed"), + } + match X25519::keypair_consistency_check(&pk2, &sk1) { + Err(KeyAgreementError::ConsistencyCheckFailed(_)) => { /* good */ } + _ => panic!("expected ConsistencyCheckFailed"), + } +} + +#[test] +fn private_key_pk() { + let (_, sk) = X25519::keygen_from_seed(&seed(ALICE_SK, KeyType::Seed)).unwrap(); + assert_eq!(sk.pk().as_bytes().as_slice(), hex::decode(ALICE_PK).unwrap()); + + let (pk, sk) = X25519::keygen().unwrap(); + assert_eq!(sk.pk(), pk); +} + +#[test] +fn private_key_consistency_check() { + let (pk1, sk1) = X25519::keygen().unwrap(); + let (pk2, sk2) = X25519::keygen().unwrap(); + sk1.consistency_check(&pk1).unwrap(); + sk2.consistency_check(&pk2).unwrap(); + + match sk1.consistency_check(&pk2) { + Err(KeyAgreementError::ConsistencyCheckFailed(_)) => { /* good */ } + _ => panic!("expected ConsistencyCheckFailed"), + } + match sk2.consistency_check(&pk1) { + Err(KeyAgreementError::ConsistencyCheckFailed(_)) => { /* good */ } + _ => panic!("expected ConsistencyCheckFailed"), + } +} + +#[test] +fn public_key_to_bytes() { + let (pk, _) = X25519::keygen_from_seed(&seed(ALICE_SK, KeyType::Seed)).unwrap(); + let expected = hex::decode(ALICE_PK).unwrap(); + + let as_ref: &[u8] = pk.as_ref(); + assert_eq!(as_ref, expected); +} + +/// Two freshly generated key pairs agree on the same, non-zero shared secret. +#[test] +fn agreement() { + for i in 0..100 { + let (pk_a, sk_a) = X25519::keygen().unwrap(); + let (pk_b, sk_b) = X25519::keygen().unwrap(); + + let ss_a = X25519::key_agreement(&pk_b, sk_a).unwrap(); + let ss_b = X25519::key_agreement(&pk_a, sk_b).unwrap(); + assert_eq!(ss_a, ss_b, "ECDH #{i}"); + } +} diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 0ca5e0cf2..3e7267429 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -1,40 +1,9 @@ -use bouncycastle_ed::x25519::*; +use bouncycastle_core::errors::KeyAgreementError; +use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; +use bouncycastle_core::traits::KeyAgreement; +use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; -// todo -- debug and turn back on -// #[test] -// fn agreement() { -// // todo ??? -// precompute(); -// -// let mut ka = [0_u8; SCALAR_SIZE]; -// let mut kb = [0_u8; SCALAR_SIZE]; -// let mut qa = [0_u8; POINT_SIZE]; -// let mut qb = [0_u8; POINT_SIZE]; -// let mut sa = [0_u8; POINT_SIZE]; -// let mut sb = [0_u8; POINT_SIZE]; -// -// let mut rng = bouncycastle_rng::DefaultRNG::new(); -// -// for i in 1..=100 { -// // Each party generates an ephemeral private key, ... -// generate_private_key_rng(&mut rng, &mut ka); -// generate_private_key(&mut rng, &mut kb); -// -// // ... publishes their public key, ... -// generate_public_key(&ka, &mut qa); -// generate_public_key(&kb, &mut qb); -// -// // ... computes the shared secret, ... -// let ra = calculate_agreement(&ka, &qb, &mut sa); -// let rb = calculate_agreement(&kb, &qa, &mut sb); -// -// // ... which is the same for both parties. -// assert_eq!(ra, rb, "ECDH #{}", i); -// assert_eq!(sa, sb, "ECDH #{}", i); -// } -// } - #[test] fn iterated() { rfc7748_check_iterated(1000); @@ -72,15 +41,15 @@ fn vector_2() { /// shared secret K that each derives from their own private key and the other's public key. #[test] fn rfc7748_section_6_1() { - let alice_sk = X25519Scalar::from_bytes(&decode_32( + let alice_sk = sk_from_bytes(&decode_32( "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", )); - let bob_sk = X25519Scalar::from_bytes(&decode_32( + let bob_sk = sk_from_bytes(&decode_32( "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb", )); - let alice_pk = generate_public_key(&alice_sk); - let bob_pk = generate_public_key(&bob_sk); + let alice_pk = alice_sk.pk(); + let bob_pk = bob_sk.pk(); check_value( alice_pk.as_bytes(), "Section 6.1 Alice's public key", @@ -93,32 +62,43 @@ fn rfc7748_section_6_1() { ); let k = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"; - let mut alice_k = [0_u8; POINT_SIZE]; - let mut bob_k = [0_u8; POINT_SIZE]; - assert!(calculate_agreement(&alice_sk, &bob_pk, &mut alice_k)); - assert!(calculate_agreement(&bob_sk, &alice_pk, &mut bob_k)); - check_value(&alice_k, "Section 6.1 K (Alice)", k); - check_value(&bob_k, "Section 6.1 K (Bob)", k); + let alice_k = X25519::key_agreement(&bob_pk, alice_sk).unwrap(); + let bob_k = X25519::key_agreement(&alice_pk, bob_sk).unwrap(); + check_value(alice_k.as_bytes(), "Section 6.1 K (Alice)", k); + check_value(bob_k.as_bytes(), "Section 6.1 K (Bob)", k); } -/// u = 0 is a point of low order, so X25519(k, 0) is the all-zero value for every scalar, the case -/// RFC 7748 Section 6.1 describes checking for. `calculate_agreement` must write the zeros and -/// return `false`. +/// [`X25519::key_agreement`] rejects a public key that gives an all-zero shared secret, however +/// that key is encoded: u = 0, u = p (non-canonical, reduces to 0), and u = 0 with the ignored +/// bit 255 set. #[test] -fn low_order_point_gives_all_zero_shared_secret() { - let k = X25519Scalar::from_bytes(&decode_32( - "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", - )); - // Start from a non-zero buffer so a missing write cannot pass by accident. - let mut out = [0xFF_u8; POINT_SIZE]; - assert!(!calculate_agreement(&k, &X25519Point::from_bytes([0; POINT_SIZE]), &mut out)); - assert_eq!(out, [0; POINT_SIZE]); +fn key_agreement_rejects_all_zero_shared_secret() { + let zero = [0_u8; POINT_SIZE]; + let p = decode_32("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"); + let mut zero_bit_255 = zero; + zero_bit_255[31] = 0x80; + + for u in [zero, p, zero_bit_255] { + let sk = sk_from_bytes(&decode_32( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + )); + match X25519::key_agreement(&X25519PublicKey::from_bytes(u), sk) { + Err(KeyAgreementError::InvalidPublicKey(_)) => { /* good */ } + other => panic!("expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), + } + } } fn decode_32(s: &str) -> [u8; 32] { hex::decode(s).unwrap().try_into().unwrap() } +/// A private key from its 32 bytes, via [`X25519::keygen_from_seed`]. +fn sk_from_bytes(bytes: &[u8]) -> X25519PrivateKey { + let seed = KeyMaterial256::from_bytes_as_type(bytes, KeyType::Seed).unwrap(); + X25519::keygen_from_seed(&seed).unwrap().1 +} + /// The iterated test of RFC 7748 Section 5.2: starting from k = u = 9, each iteration sets k to /// `X25519(k, u)` and u to the old k, and k is checked after 1, 1,000 and 1,000,000 iterations. /// @@ -132,14 +112,12 @@ fn rfc7748_check_iterated(count: usize) { k[0] = 9; let mut u = [0_u8; POINT_SIZE]; u[0] = 9; - let mut r = [0_u8; POINT_SIZE]; - let mut iterations = 0; while iterations < count { - calculate_agreement(&X25519Scalar::from_bytes(&k), &X25519Point::from_bytes(u), &mut r); + let r = X25519::key_agreement(&X25519PublicKey::from_bytes(u), sk_from_bytes(&k)).unwrap(); u = k; - k = r; + k = *r.as_bytes(); iterations += 1; @@ -170,10 +148,9 @@ fn check_value(n: &[u8], text: &str, se: &str) { } fn check_vector(sk: &str, su: &str, se: &str, text: &str) { - let k = X25519Scalar::from_bytes(&decode_32(sk)); - let u = X25519Point::from_bytes(decode_32(su)); + let k = sk_from_bytes(&decode_32(sk)); + let u = X25519PublicKey::from_bytes(decode_32(su)); - let mut r = [0_u8; POINT_SIZE]; - calculate_agreement(&k, &u, &mut r); - check_value(&r, text, se); + let r = X25519::key_agreement(&u, k).unwrap(); + check_value(r.as_bytes(), text, se); } diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index f96d32de0..428631a37 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -8,13 +8,14 @@ //! Every case is either `valid` or `acceptable`, and every case has an expected shared secret, so //! each one is checked for exact output. The `acceptable` cases are low-order, twist and //! non-canonical public keys, which RFC 7748 Section 5 requires to be processed rather than -//! rejected. Those whose shared secret is all zeros (flag `ZeroSharedSecret`) are also where -//! `calculate_agreement` returns `false`. +//! rejected. The exception is those whose shared secret is all zeros (flag `ZeroSharedSecret`), +//! which `key_agreement` rejects with `KeyAgreementError::InvalidPublicKey`. +use bouncycastle_core::errors::KeyAgreementError; +use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; +use bouncycastle_core::traits::KeyAgreement; use bouncycastle_core_test_framework::test_data_loaders::{Value, hex_field, wycheproof_json}; -use bouncycastle_ed::x25519::{ - POINT_SIZE, SCALAR_SIZE, X25519Point, X25519Scalar, calculate_agreement, -}; +use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; #[test] fn wycheproof_x25519() { @@ -42,12 +43,15 @@ fn wycheproof_x25519() { hex_field(test, "public", tc_id).try_into().expect("a 32-byte public key"); let shared = hex_field(test, "shared", tc_id); - let mut out = [0u8; POINT_SIZE]; - let (k, u) = (X25519Scalar::from_bytes(&k), X25519Point::from_bytes(u)); - let nonzero = calculate_agreement(&k, &u, &mut out); - assert_eq!(out.as_slice(), shared.as_slice(), "{ctx}: shared secret"); - assert_eq!(nonzero, shared.iter().any(|&b| b != 0), "{ctx}: return value"); - zero_shared += usize::from(!nonzero); + let (k, u) = (sk_from_bytes(&k), X25519PublicKey::from_bytes(u)); + match X25519::key_agreement(&u, k) { + Ok(r) => assert_eq!(r.as_bytes(), shared.as_slice(), "{ctx}: shared secret"), + Err(KeyAgreementError::InvalidPublicKey(_)) => { + assert!(shared.iter().all(|&b| b == 0), "{ctx}: rejected a non-zero secret"); + zero_shared += 1; + } + Err(e) => panic!("{ctx}: unexpected error {e:?}"), + } } } @@ -61,3 +65,9 @@ fn wycheproof_x25519() { "{filename}: expected every kind of case" ); } + +/// A private key from its 32 bytes, via [`X25519::keygen_from_seed`]. +fn sk_from_bytes(bytes: &[u8]) -> X25519PrivateKey { + let seed = KeyMaterial256::from_bytes_as_type(bytes, KeyType::Seed).unwrap(); + X25519::keygen_from_seed(&seed).unwrap().1 +} From a467ea3364670945420cc09770ab7c684b5ed99c Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 12:23:58 -0500 Subject: [PATCH 15/26] Impl'd KEMEncapsulator / KEMDecapsulator for x25519 via the new crypto/hpke/dhkem implementation. Also started crate docs. --- Cargo.toml | 2 + crypto/core/src/errors.rs | 2 + crypto/core/src/traits.rs | 25 +- crypto/ed/Cargo.toml | 3 +- crypto/ed/ed_todo.md | 39 ++- crypto/ed/src/lib.rs | 8 +- crypto/ed/src/x25519.rs | 390 ++++++++++++++++++++++---- crypto/ed/tests/x25519_dhkem_tests.rs | 285 +++++++++++++++++++ crypto/ed/tests/x25519_key_tests.rs | 14 - crypto/ed/tests/x25519_tests.rs | 10 +- crypto/ed/tests/x25519_wycheproof.rs | 2 +- crypto/hpke/Cargo.toml | 9 + crypto/hpke/src/dhkem.rs | 300 ++++++++++++++++++++ crypto/hpke/src/lib.rs | 37 +++ src/lib.rs | 1 + 15 files changed, 1029 insertions(+), 98 deletions(-) create mode 100644 crypto/ed/tests/x25519_dhkem_tests.rs create mode 100644 crypto/hpke/Cargo.toml create mode 100644 crypto/hpke/src/dhkem.rs create mode 100644 crypto/hpke/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index c11a03f02..af1f019f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ bouncycastle-factory = { path = "./crypto/factory" } bouncycastle-hex = { path = "./crypto/hex" } bouncycastle-hkdf = { path = "./crypto/hkdf" } bouncycastle-hmac = { path = "./crypto/hmac" } +bouncycastle-hpke = { path = "./crypto/hpke" } bouncycastle-mlkem = { path = "./crypto/mlkem" } bouncycastle-mlkem-lowmemory = { path = "./crypto/mlkem-lowmemory" } bouncycastle-mldsa = { path = "./crypto/mldsa" } @@ -58,6 +59,7 @@ bouncycastle-factory.workspace = true bouncycastle-hex.workspace = true bouncycastle-hkdf.workspace = true bouncycastle-hmac.workspace = true +bouncycastle-hpke.workspace = true bouncycastle-mldsa.workspace = true bouncycastle-mldsa-lowmemory.workspace = true bouncycastle-mlkem.workspace = true diff --git a/crypto/core/src/errors.rs b/crypto/core/src/errors.rs index c2ca37820..dc1cdfca2 100644 --- a/crypto/core/src/errors.rs +++ b/crypto/core/src/errors.rs @@ -73,6 +73,8 @@ pub enum KEMError { DecapsulationFailed, /// DecodingError(&'static str), + /// The public key given to encapsulation is unusable, for example a point of small order. + InvalidPublicKey(&'static str), /// KeyGenError(&'static str), /// diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index c8be367bc..27fb1fb65 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1085,24 +1085,25 @@ pub trait KEMPublicKey: } /// A Key Agreement algorithm, often called a Diffie-Hellman algorithm. -/// The core function is `key_agreement(&pk, &sk) -> OutputType` which acts on one public key and one private key. -/// The return type of `key_agreement` will depend on the specific algorithm -- often it will be a `[u8]`, but it -/// may be a struct, such as a public key. +/// The core function is `key_agreement(&pk, &sk)`, which combines one party's public key with the +/// other party's private key, and returns the shared secret as a value of the public key type `PK`. pub trait KeyAgreement: Sized { - /// The output type of [`KeyAgreement::key_agreement`]. - /// It may be any type that makes sense, but it must impl `AsRef<[u8]>` so that it can be, for - /// example, trivially used as input to a KDF or hash function. - type SharedSecretType: AsRef<[u8]>; - - /// Perform a key agreement - fn key_agreement(pk: &PK, sk: SK) -> Result; + /// Perform a key agreement. + /// Note that this returns a public key. The caller is responsible for using a [`KDF`] to + /// derive a shared symmetric key from it. + /// Alternatively, most algorithms that impl [`KeyAgreement`] should also impl + /// [`KEMEncapsulator`] / [`KEMDecapsulator`] + fn key_agreement(pk: &PK, sk: &SK) -> Result; } /// A public key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "pk". -pub trait KeyAgreementPublicKey {} +/// The bound on `AsRef<[u8]>` is because a Diffie-Hellman key agreement returns a public key as +/// the shared secret value, so it must be easy to convert this to bytes in a cannonical representation +/// so that it can be fed into a [`KDF`]. +pub trait KeyAgreementPublicKey: Sized + AsRef<[u8]> {} /// A private key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "sk" (for "secret key"). -pub trait KeyAgreementPrivateKey {} +pub trait KeyAgreementPrivateKey: Sized {} /// A Message Authentication Code algorithm is a keyed hash function that behaves somewhat like a symmetric signature function. /// A MAC algorithm takes in a key and some data, and produces a MAC (message authentication code) that diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index 11bd2affe..ff3805be0 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -5,11 +5,12 @@ version.workspace = true [dependencies] bouncycastle-core.workspace = true +bouncycastle-hpke.workspace = true bouncycastle-rng.workspace = true +bouncycastle-sha2.workspace = true bouncycastle-utils.workspace = true [dev-dependencies] bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true bouncycastle-rng.workspace = true -bouncycastle-sha2.workspace = true diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 8f56cb7b1..b2f7cf027 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -47,16 +47,21 @@ Within each, the priority order is: ## Phase 2: Traits, Bells & Whistles - [ ] Finish the `KeyAgreement` / `KeyAgreementPublicKey` / `KeyAgreementPrivateKey` traits in - `traits.rs`. Today they are empty markers. They need: + `traits.rs`. They need: - [ ] key `from_bytes`/encoding, by analogy with `KEMPublicKey`/`KEMPrivateKey` (lengths as - const generics); - - [ ] the output type, wrapped in `KeyMaterial` (the shared secret is sensitive); - - [ ] an error type. + const generics). Partly there: `KeyAgreementPublicKey` requires `AsRef<[u8]>`, and the X25519 + keys get `from_bytes`/`encode` from `KEMPublicKey`/`KEMPrivateKey`, but the `KeyAgreement*` + traits themselves have no decoding. + - [x] the output type. Decided: `key_agreement` returns a `PK`, and the caller applies a KDF; + the KEM traits are the API that hands back a `KeyMaterial` shared secret. + - [x] an error type: `KeyAgreementError`. - [x] Add X25519 key types: `X25519PrivateKey` (`Secret`-backed, zeroized, redacted `Debug`) and `X25519PublicKey`, used by `X25519::key_agreement` and `X25519PrivateKey::pk`. -- [x] Add `struct X25519` (`Algorithm`), and re-export it and the key types at the crate root. -- [ ] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. -- [x] Re-enable the `agreement` round-trip test, now in `x25519_key_tests.rs` on `X25519::keygen`. +- [x] Add `struct X25519` (`Algorithm`). It and the key types are public through `pub mod x25519` + (no crate-root re-exports). +- [x] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. +- [x] Re-enable the `agreement` round-trip test. Later removed as redundant: the RFC 7748 iterated + and Wycheproof known answers, and the KEM framework round trip, cover it. - [ ] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from X25519. - [x] Decide what happens on an all-zero shared secret (low-order public key): `key_agreement` @@ -64,18 +69,19 @@ Within each, the priority order is: RFC 8446 §7.4.2 and RFC 9180 §7.1.4 make it a MUST. - [ ] Open: detect a small-order public key before the private-key operation, rather than from the output. -- [ ] A `KEM` impl over X25519 (DHKEM-style). Mentioned in an `x25519.rs` TODO; decide if it - belongs here. +- [x] A `KEM` impl over X25519: `KEMEncapsulator`/`KEMDecapsulator` on `X25519` as DHKEM (X25519, + HKDF-SHA256) (RFC 9180 §4.1), built on the generic `DHKEM` in the new `bouncycastle-hpke` + utility crate. Tested against RFC 9180 A.1.1/A.1.2 and the core-test-framework KEM suites. - [x] Keygen, mirroring ML-KEM, on `X25519` with `KeyAgreementError` (added to `core`): - [x] `keygen` (OS-seeded `HashDRBG_SHA512`) and `keygen_from_rng`; - [x] `keygen_from_seed`, from a 32-byte `KeyMaterial256`; - [x] `keypair_consistency_check`. No `keygen_from_seed_and_encoded`: the seed *is* the private key encoding, so there is nothing to compare. -- [ ] Fast fixed-base `scalar_mult_base`. Today it runs the generic ladder with u = 9; there is a - commented-out Edwards-form path. - [ ] Wire into the factory (a `KeyAgreementFactory`, if that fits the factory pattern) and add a CLI subcommand (`cli/src/*_cmd.rs`). -- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). +- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). Scoped runs done on the KEM code (one survivor: an + error-mapping arm unreachable with a well-behaved RNG) and on `bouncycastle-hpke`; + a full-crate run is still to do. ## Phase 3: Docs and ergonomics @@ -90,8 +96,13 @@ Within each, the priority order is: - [x] Remove the answered "why are we cloning?" TODO. - [ ] Docs: - [ ] Re-enable `#![forbid(missing_docs)]`. - - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md. - - [ ] Is XDH in a NIST document? (`lib.rs` `# Standards`.) + - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md: + - [x] Usage Examples (`lib.rs`, linking to the `x25519` module's examples); + - [ ] Memory Usage (needs the benches below); + - [ ] Security Considerations. + - [x] Is XDH in a NIST document? No: RFC 7748 only. SP 800-186 specifies the curves but does not + list them for key establishment, and SP 800-56A Rev. 3 does not include XDH (`lib.rs` + `# Standards`). - [ ] Benches: criterion, plus a `mem_usage_benches` harness and a `# Memory Usage` docs section. - [ ] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index 2c9ff647d..4c8742518 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -9,7 +9,10 @@ //! * FIPS 186-5 section 7 specifies the EdDSA digital signature algorithms, essentially re-published from RFC 8032. //! * NIST SP 800-186 gives an excellent treatment of the mathematics of all the families of elliptic curves used in cryptography, including Edwards curves. //! -//! TODO -- is XDH specified in a NIST doc? +//! XDH (X25519 and X448) is specified only in RFC 7748. NIST SP 800-186 §3.2.2 specifies the +//! curves Curve25519 and Curve448, but its Table 2 does not list them for key establishment, and +//! NIST SP 800-56A Rev. 3 does not include XDH, so X25519 and X448 are not NIST-approved key +//! agreement schemes. //! //! # Usage Examples //! @@ -22,8 +25,5 @@ // TODO -- #![forbid(missing_docs)] // Add back at the end -// TODO -- why is this needed? -extern crate alloc; - mod curve25519; pub mod x25519; diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 0dc27994c..18f8a8b6d 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -11,6 +11,26 @@ //! [`X25519::keygen_from_rng`], or [`X25519::keygen_from_seed`] with a 32-byte seed, which is also //! how an existing private key is imported. //! +//! Private keys can also be generated from a 32-byte seed. The only requirement is that they be +//! 32 bytes long (ie a full [`KeyMaterial256`] or equivalent, +//! [`KeyType::Seed`] or [`KeyType::CryptographicRandom`] and be at least [`SecurityStrength::_128bit`]. +//! +//! ```rust +//! use bouncycastle_ed::x25519::X25519; +//! use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +//! use bouncycastle_core::hazmat::do_hazardous_operations; +//! +//! // Create a seed. +//! // We're using a do_hazardous_operations() closure to prevent the library from complaining +//! // that the key material is week. +//! let mut seed = KeyMaterial256::new(); +//! do_hazardous_operations(&mut seed, |seed| { +//! seed.set_bytes_as_type(&[0x02; 32], KeyType::Seed) +//! }).unwrap(); +//! +//! let (pk, sk) = X25519::keygen().unwrap(); +//! ``` +//! //! ## X25519 key agreement //! //! Alice and Bob each generate a key pair and send each other their public keys. Each then combines @@ -35,17 +55,20 @@ //! let (alice_pk, alice_sk) = X25519::keygen().unwrap(); //! let (bob_pk, bob_sk) = X25519::keygen().unwrap(); //! -//! // Each computes the shared secret K from their own private key and the other's public key. +//! // Each performs a key agreement from their own private key and the other's public key, +//! // each arriving at the same k. +//! // Note that k is an X25519PublicKey and could be used again for another key agreement. +//! // But this example will proceed with deriving a shared symmetric key. //! // An error here means the other party's public key is unusable; abort the exchange. -//! let alice_k = X25519::key_agreement(&bob_pk, alice_sk).expect("Bad public key"); -//! let bob_k = X25519::key_agreement(&alice_pk, bob_sk).expect("Bad public key"); +//! let alice_k = X25519::key_agreement(&bob_pk, &alice_sk).expect("Bad public key"); +//! let bob_k = X25519::key_agreement(&alice_pk, &bob_sk).expect("Bad public key"); //! -//! // Both derive the symmetric key from K, with both public keys as context, -//! // as recommended in RFC 7748 §6.1. +//! // RFC 7748 §6.1 recommends using a key derivation function (KDF) over K, K_A, and K_B +//! // to derive a symmetric key. This example will use HKDF_SHA256. //! fn derive_key_rfc7748( //! k: &X25519PublicKey, -//! alice_pk: &X25519PublicKey, -//! bob_pk: &X25519PublicKey) -> KeyMaterial256 { +//! pk_A: &X25519PublicKey, +//! pk_B: &X25519PublicKey) -> KeyMaterial256 { //! let mut ikm = //! KeyMaterial256::from_bytes_as_type(k.as_ref(), KeyType::CryptographicRandom).unwrap(); //! // X25519 provides 128-bit security, not the 256 bits a 32-byte key would imply. @@ -54,40 +77,113 @@ //! ikm.set_security_strength(SecurityStrength::_128bit).unwrap(); //! //! let mut context = [0u8; 64]; -//! context[..32].copy_from_slice(alice_pk.as_ref()); -//! context[32..].copy_from_slice(bob_pk.as_ref()); +//! context[..32].copy_from_slice(pk_A.as_ref()); +//! context[32..].copy_from_slice(pk_B.as_ref()); //! //! let mut key = KeyMaterial256::new(); //! HKDF_SHA256::new().derive_key_out(&ikm, &context, &mut key).unwrap(); //! key //! } //! -//! // Each derives `key` independently. +//! // Each derives the symmetric key independently. //! // Note that they have to agree on whose key goes in first; //! // if Alice does `(alice_pk, bob_pk)` and bob does `(bob_pk, alice_pk)` then they will //! // arrive at different output keys. -//! // Typically this will be specified unambiguously by the protocol, such as -//! // (sender_pk, recipient_pk). +//! // Typically, this will be specified unambiguously by the protocol, such as +//! // (sender_pk, recipient_pk) since Alice and Bob are expected to know whether they +//! // are the sender or receiver. //! let alice_key = derive_key_rfc7748(&alice_k, &alice_pk, &bob_pk); //! let bob_key = derive_key_rfc7748(&bob_k, &alice_pk, &bob_pk); //! -//! // Alice and Bob now hold the same 32-byte, 128-bit-strength symmetric KeyMaterial. +//! // Alice and Bob now hold the same 32-byte KeyMaterial256. //! assert!(alice_key.equals(&bob_key)); //! assert_eq!(alice_key.key_len(), 32); +//! +//! // Which has been set at 128-bit-strength. //! assert_eq!(alice_key.key_type(), KeyType::CryptographicRandom); //! assert_eq!(alice_key.security_strength(), SecurityStrength::_128bit); //! ``` //! +//! ## X25519 as a KEM +//! +//! [`X25519`] also implements [`KEMEncapsulator`] and [`KEMDecapsulator`] as DHKEM(X25519, +//! HKDF-SHA256) of RFC 9180 §4.1, the KEM used by HPKE. The sender encapsulates against the +//! recipient's public key, and gets a shared secret and a 32-byte ciphertext (an ephemeral public +//! key) to send. The shared secret is already the output of a KDF, ready to use as a key. +//! +//! ```rust +//! use bouncycastle_core::traits::{KEMDecapsulator, KEMEncapsulator}; +//! use bouncycastle_ed::x25519::X25519; +//! +//! let (pk, sk) = X25519::keygen().unwrap(); +//! +//! // The sender encapsulates against the recipient's public key... +//! let (ss, ct) = X25519::encaps(&pk).unwrap(); +//! +//! // ...and the recipient recovers the same shared secret from the ciphertext. +//! let ss1 = X25519::decaps(&sk, &ct).unwrap(); +//! assert_eq!(ss, ss1); +//! ``` +//! +//! ## Chaining together key agreement operations +//! +//! The output of a Diffie-Hellman key agreement is itself a valid public key, so key agreements can +//! be chained. +//! Below is an example of a simple three-party agreement, in which Alice, Bob and Charlie all arrive +//! at the same shared secret `k`, by combining their private key with public data from the other two. +//! The trick here is that each party does a pairwise key agreement with each other party, +//! and then another agreement with the result of the other two parties' agreements with each other. +//! This is a simple scheme to illustrate the point and obviously does not scale well to large groups. +//! +//! ```rust +//! use bouncycastle_core::traits::KeyAgreement; +//! use bouncycastle_ed::x25519::X25519; +//! +//! let (alice_pk, alice_sk) = X25519::keygen().unwrap(); +//! let (bob_pk, bob_sk) = X25519::keygen().unwrap(); +//! let (charlie_pk, charlie_sk) = X25519::keygen().unwrap(); +//! +//! // Assume that Alice, Bob, and Charlie have all exchanged public keys. +//! // Round 1: each pair computes a pairwise value. Key agreement is symmetric, so `ab_k` is +//! // computed by both Alice and Bob; only one side of each pair is shown. +//! let ab_k = X25519::key_agreement(&bob_pk, &alice_sk).expect("Bad public key"); +//! let bc_k = X25519::key_agreement(&charlie_pk, &bob_sk).expect("Bad public key"); +//! let ac_k = X25519::key_agreement(&charlie_pk, &alice_sk).expect("Bad public key"); +//! +//! // Round 2: each pairwise value is sent, in the clear, to the third party, who combines it with +//! // their own private key. +//! // Since it is no longer secret, `ab_k` must not also be used as a secret key between +//! // Alice and Bob. +//! let alice_k = X25519::key_agreement(&bc_k, &alice_sk).expect("Bad public key"); +//! let bob_k = X25519::key_agreement(&ac_k, &bob_sk).expect("Bad public key"); +//! let charlie_k = X25519::key_agreement(&ab_k, &charlie_sk).expect("Bad public key"); +//! +//! // The computations ran in different orders, but by the magic of +//! // commutative algebra, they all end up with the same shared secret key `k`. +//! assert!(alice_k.eq(&bob_k)); +//! assert!(bob_k.eq(&charlie_k)); +//! +//! // As in the two-party case, `k` is not uniformly random: run it through a suitable KDF scheme +//! // to derive a usable and secure symmetric key. +//! ``` +//! +//! Like any unauthenticated Diffie-Hellman, this resists only passive eavesdroppers. An active +//! attacker who can substitute public keys or pairwise values in transit can sit in the middle, so a +//! real protocol must authenticate them, for example by signing them. -use bouncycastle_core::errors::{KeyAgreementError, RNGError}; -use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +use bouncycastle_core::errors::{KEMError, KeyAgreementError, RNGError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterial256, KeyMaterialTrait, KeyType}; use bouncycastle_core::security_strength::SecurityStrength; use bouncycastle_core::traits::{ - Algorithm, KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey, RNG, + Algorithm, KEMDecapsulator, KEMEncapsulator, KEMPrivateKey, KEMPublicKey, KeyAgreement, + KeyAgreementPrivateKey, KeyAgreementPublicKey, RNG, }; +use bouncycastle_hpke::dhkem::{DHKEM, DHKEMParams}; use bouncycastle_rng::HashDRBG_SHA512; -use bouncycastle_utils::ct::{Condition, ct_eq_zero_bytes}; +use bouncycastle_sha2::SHA256; +use bouncycastle_utils::ct::{Condition, ct_eq_bytes, ct_eq_zero_bytes}; use bouncycastle_utils::secret::Secret; +use core::fmt::{self, Display, Formatter}; use crate::curve25519::FieldElement25519; @@ -96,12 +192,32 @@ use crate::curve25519::FieldElement25519; // and an Ed25519 point is an encoded y-coordinate plus a sign bit (RFC 8032 §5.1.2), so neither // fits these. +/*** Constants ***/ + pub const POINT_SIZE: usize = 32; pub const SCALAR_SIZE: usize = 32; /// String name of the X25519 algorithm. pub const X25519_NAME: &str = "X25519"; +/// The base point of curve25519, `U(P) = 9` (Section 4.1 of RFC7748). +const BASE_POINT: X25519PublicKey = { + let mut u = [0_u8; POINT_SIZE]; + u[0] = 9; + X25519PublicKey(u) +}; + +/// The coefficient A of curve25519, `v^2 = u^3 + A*u^2 + u` (RFC 7748 §4.1). +const C_A: u32 = 486662; + +/// `(A + 2) / 4 = 121666`, the ladder's doubling constant. +/// +/// Deviation from the RFC: RFC 7748 §5 uses `a24 = (486662 - 2) / 4 = 121665` in +/// `z_2 = E * (AA + a24 * E)`. Since `E = AA - BB`, `AA + a24 * E = BB + (a24 + 1) * E`, so pairing +/// the constant with BB instead needs `a24 + 1 = (A + 2) / 4`. [`scalar_mult`] and +/// [`ladder_double`] both use that form. +const C_A24: u32 = (C_A + 2) / 4; + /// The X25519 key agreement algorithm of RFC 7748 §5. pub struct X25519; @@ -177,8 +293,6 @@ impl X25519 { } impl KeyAgreement for X25519 { - type SharedSecretType = X25519PublicKey; - /// The X25519 Diffie-Hellman of RFC 7748 §6.1: computes the shared secret /// `K = X25519(sk, pk)`, the u-coordinate of the scalar multiple of the peer's public key by /// our private key, as its canonical 32-byte little-endian encoding. @@ -211,23 +325,30 @@ impl KeyAgreement for X25519 { /// information it reveals about `K` is whether it is zero. fn key_agreement( pk: &X25519PublicKey, - sk: X25519PrivateKey, + sk: &X25519PrivateKey, ) -> Result { - let r = scalar_mult(&sk, pk); - if r.is_zero() { - return Err(KeyAgreementError::InvalidPublicKey( - "Public key has small order: the shared secret is all zeros", - )); - } - Ok(r) + dh(sk, pk) } } +/// `X25519(sk, pk)`, rejecting an all-zero output; see [`X25519::key_agreement`]. +fn dh(sk: &X25519PrivateKey, pk: &X25519PublicKey) -> Result { + let r = scalar_mult(sk, pk); + if r.is_zero() { + return Err(KeyAgreementError::InvalidPublicKey( + "Public key has small order: the shared secret is all zeros", + )); + } + Ok(r) +} + /// An X25519 private key: a 32-byte scalar, as defined in Section 5 of RFC7748. /// -/// Any 32 bytes are a valid scalar. The fixed bits `decodeScalar25519` sets and clears are applied -/// each time the scalar is used, not on construction, so the stored bytes are exactly the ones the -/// caller supplied. Held in a [`Secret`], so it is zeroized on drop and redacted from `Debug`. +/// Any 32 bytes are a valid scalar. The fixed bits `decodeScalar25519` sets and clears +/// ("clamping") are applied each time the scalar is used, so keys that differ only in those bits +/// are the same key, and compare equal. Held in a [`Secret`], so it is zeroized on drop and +/// redacted from `Debug`. +#[derive(Clone)] pub struct X25519PrivateKey(Secret<[u8; SCALAR_SIZE]>); impl X25519PrivateKey { @@ -250,10 +371,34 @@ impl X25519PrivateKey { Err(KeyAgreementError::ConsistencyCheckFailed("Public key does not match private key")) } } + + /// The key bytes with `decodeScalar25519`'s fixed bits applied (RFC 7748 §5): + /// + /// ```text + /// k_list[0] &= 248 + /// k_list[31] &= 127 + /// k_list[31] |= 64 + /// ``` + fn clamped(&self) -> Secret<[u8; SCALAR_SIZE]> { + let mut k = self.0.clone(); + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; + k + } } impl KeyAgreementPrivateKey for X25519PrivateKey {} +/// Constant-time comparison of the clamped scalars. +impl PartialEq for X25519PrivateKey { + fn eq(&self, other: &Self) -> bool { + ct_eq_bytes(&*self.clamped(), &*other.clamped()) + } +} + +impl Eq for X25519PrivateKey {} + /// An X25519 public value: the u-coordinate of a point on curve25519, as 32 little-endian bytes /// (Section 5 of RFC7748). /// @@ -291,23 +436,16 @@ impl AsRef<[u8]> for X25519PublicKey { } } -/// The base point of curve25519, `U(P) = 9` (Section 4.1 of RFC7748). -const BASE_POINT: X25519PublicKey = { - let mut u = [0_u8; POINT_SIZE]; - u[0] = 9; - X25519PublicKey(u) -}; - -/// The coefficient A of curve25519, `v^2 = u^3 + A*u^2 + u` (RFC 7748 §4.1). -const C_A: u32 = 486662; - -/// `(A + 2) / 4 = 121666`, the ladder's doubling constant. -/// -/// Deviation from the RFC: RFC 7748 §5 uses `a24 = (486662 - 2) / 4 = 121665` in -/// `z_2 = E * (AA + a24 * E)`. Since `E = AA - BB`, `AA + a24 * E = BB + (a24 + 1) * E`, so pairing -/// the constant with BB instead needs `a24 + 1 = (A + 2) / 4`. [`scalar_mult`] and -/// [`ladder_double`] both use that form. -const C_A24: u32 = (C_A + 2) / 4; +/// The 32 bytes in hex. +impl Display for X25519PublicKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "X25519PublicKey {{ ")?; + for b in &self.0 { + write!(f, "{b:02x}")?; + } + write!(f, " }}") + } +} /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: /// @@ -547,3 +685,161 @@ fn scalar_mult_base(k: &X25519PrivateKey) -> X25519PublicKey { // precompute lookup tables for the multiples of BASE_POINT. TODO scalar_mult(k, &BASE_POINT) } + +/*** DHKEM ***/ + +// This section provides the machinery to promote x25519 to a KEM via RFC9180's DHKEM construction. + +/// The length of the shared secret from [`X25519`]'s KEM: Nsecret for DHKEM(X25519, HKDF-SHA256) +/// (RFC 9180 §7.1). +pub const DHKEM_SS_LEN: usize = 32; + +/// DHKEM(X25519, HKDF-SHA256) of RFC 9180 §4.1. Nenc = Npk = Ndh = 32 and Nsk = 32 (§7.1). +type X25519DHKEM = DHKEM; + +/// DHKEM(X25519, HKDF-SHA256): kem_id 0x0020, with Nenc = Npk = Nsk = 32 (RFC 9180 §7.1). +impl DHKEMParams for X25519 { + type PublicKey = X25519PublicKey; + type PrivateKey = X25519PrivateKey; + type KDFHash = SHA256; + const KEM_ID: u16 = 0x0020; + + fn generate_key_pair_rng( + rng: &mut dyn RNG, + ) -> Result<(X25519PublicKey, X25519PrivateKey), KEMError> { + // RFC 9180 §4: "GenerateKeyPair can be implemented as DeriveKeyPair(random(Nsk))"; for + // X25519, 32 random bytes are already a private key (RFC 7748 §6.1), so they are used as is. + Self::keygen_from_rng(rng).map_err(|e| match e { + KeyAgreementError::RNGError(e) => KEMError::RNGError(e), + KeyAgreementError::KeyGenError(msg) => KEMError::KeyGenError(msg), + _ => KEMError::GenericError("X25519 key generation failed"), + }) + } + + fn private_key_from_bytes(sk: &[u8; SCALAR_SIZE]) -> X25519PrivateKey { + let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + *k = *sk; + X25519PrivateKey(k) + } + + fn pk(sk: &X25519PrivateKey) -> X25519PublicKey { + sk.pk() + } + + fn dh( + sk: &X25519PrivateKey, + pk: &X25519PublicKey, + ) -> Result, KeyAgreementError> { + let mut k: Secret<[u8; POINT_SIZE]> = Secret::new(); + *k = dh(sk, pk)?.0; + Ok(k) + } + + // RFC 9180 §7.1.1: "For X25519 and X448, the SerializePublicKey() and DeserializePublicKey() + // functions are the identity function". + fn serialize_public_key(pk: &X25519PublicKey) -> [u8; POINT_SIZE] { + pk.0 + } + + fn deserialize_public_key(pkm: &[u8; POINT_SIZE]) -> Result { + Ok(X25519PublicKey(*pkm)) + } +} + +/// X25519 as a KEM: DHKEM(X25519, HKDF-SHA256) of RFC 9180 §4.1, where the ciphertext is an +/// ephemeral public key (`enc`) and the shared secret is derived with HKDF-SHA256 from the X25519 +/// output and both public keys. +impl KEMEncapsulator for X25519 { + /// `Encap(pkR)` (RFC 9180 §4.1), drawing the ephemeral key pair from the library's default RNG, + /// seeded from the OS. + /// + /// # Errors + /// + /// [`KEMError::InvalidPublicKey`] if `pk` gives an all-zero X25519 output, as for + /// [`X25519::key_agreement`]. + fn encaps( + pk: &X25519PublicKey, + ) -> Result<(KeyMaterial, [u8; POINT_SIZE]), KEMError> { + let mut os_rng = HashDRBG_SHA512::new_from_os(); + Self::encaps_rng(pk, &mut os_rng) + } + + /// `Encap(pkR)` (RFC 9180 §4.1), drawing the ephemeral key pair from `rng`. + /// + /// # Errors + /// + /// * [`KEMError::RNGError`] if `rng`'s security strength is below 128 bits. + /// * [`KEMError::InvalidPublicKey`] if `pk` gives an all-zero X25519 output, as for + /// [`X25519::key_agreement`]. + fn encaps_rng( + pk: &X25519PublicKey, + rng: &mut dyn RNG, + ) -> Result<(KeyMaterial, [u8; POINT_SIZE]), KEMError> { + X25519DHKEM::encap_rng(pk, rng) + } +} + +impl KEMDecapsulator for X25519 { + /// `Decap(enc, skR)` (RFC 9180 §4.1), with the ciphertext `ct` as `enc`. + /// + /// # Errors + /// + /// * [`KEMError::LengthError`] if `ct` is not 32 bytes. + /// * [`KEMError::DecapsulationFailed`] if `ct` gives an all-zero X25519 output. + fn decaps(sk: &X25519PrivateKey, ct: &[u8]) -> Result, KEMError> { + let enc: &[u8; POINT_SIZE] = ct + .try_into() + .map_err(|_| KEMError::LengthError("An X25519 KEM ciphertext must be 32 bytes"))?; + X25519DHKEM::decap(enc, sk) + } +} + +/// The encoding is the 32 key bytes, clamped: RFC 9180 §7.1.2 says "The SerializePrivateKey() +/// function MUST clamp its output and the DeserializePrivateKey() function MUST clamp its input". +impl KEMPrivateKey for X25519PrivateKey { + fn encode(&self) -> [u8; SCALAR_SIZE] { + *self.clamped() + } + + fn encode_out(&self, out: &mut [u8; SCALAR_SIZE]) -> usize { + out.fill(0); + *out = *self.clamped(); + SCALAR_SIZE + } + + /// # Errors + /// + /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. + fn from_bytes(bytes: &[u8]) -> Result { + let bytes: &[u8; SCALAR_SIZE] = bytes + .try_into() + .map_err(|_| KEMError::DecodingError("An X25519 private key must be 32 bytes"))?; + let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + *k = *bytes; + Ok(Self(X25519PrivateKey(k).clamped())) + } +} + +/// The encoding is the 32 bytes of the u-coordinate, as is: RFC 9180 §7.1.1 makes serialization +/// the identity function for X25519. +impl KEMPublicKey for X25519PublicKey { + fn encode(&self) -> [u8; POINT_SIZE] { + self.0 + } + + fn encode_out(&self, out: &mut [u8; POINT_SIZE]) -> usize { + out.fill(0); + *out = self.0; + POINT_SIZE + } + + /// # Errors + /// + /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. + fn from_bytes(bytes: &[u8]) -> Result { + let bytes: &[u8; POINT_SIZE] = bytes + .try_into() + .map_err(|_| KEMError::DecodingError("An X25519 public key must be 32 bytes"))?; + Ok(Self(*bytes)) + } +} diff --git a/crypto/ed/tests/x25519_dhkem_tests.rs b/crypto/ed/tests/x25519_dhkem_tests.rs new file mode 100644 index 000000000..204a0fb0c --- /dev/null +++ b/crypto/ed/tests/x25519_dhkem_tests.rs @@ -0,0 +1,285 @@ +//! X25519 as a KEM: DHKEM(X25519, HKDF-SHA256) of RFC 9180 Section 4.1, against the RFC 9180 +//! Appendix A.1 vectors and the core-test-framework KEM suites. + +use bouncycastle_core::errors::{KEMError, RNGError}; +use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; +use bouncycastle_core::security_strength::SecurityStrength; +use bouncycastle_core::traits::{ + KEMDecapsulator, KEMEncapsulator, KEMPrivateKey, KEMPublicKey, RNG, +}; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_core_test_framework::kem::{TestFrameworkKEM, TestFrameworkKEMKeys}; +use bouncycastle_ed::x25519::{ + DHKEM_SS_LEN, POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey, +}; +use bouncycastle_hex as hex; +use bouncycastle_hpke::dhkem::DHKEM; + +type X25519DHKEM = DHKEM; + +/// The DHKEM values of one RFC 9180 Appendix A.1 vector set. +struct Vector { + ikm_e: &'static str, + pk_em: &'static str, + sk_em: &'static str, + ikm_r: &'static str, + pk_rm: &'static str, + sk_rm: &'static str, + enc: &'static str, + shared_secret: &'static str, +} + +/// RFC 9180 Appendix A.1.1 (Base mode) and A.1.2 (PSK mode). The PSK only enters the HPKE key +/// schedule, so for the KEM these are two independent sets. +const VECTORS: [Vector; 2] = [ + Vector { + ikm_e: "7268600d403fce431561aef583ee1613527cff655c1343f29812e66706df3234", + pk_em: "37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431", + sk_em: "52c4a758a802cd8b936eceea314432798d5baf2d7e9235dc084ab1b9cfa2f736", + ikm_r: "6db9df30aa07dd42ee5e8181afdb977e538f5e1fec8a06223f33f7013e525037", + pk_rm: "3948cfe0ad1ddb695d780e59077195da6c56506b027329794ab02bca80815c4d", + sk_rm: "4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8", + enc: "37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431", + shared_secret: "fe0e18c9f024ce43799ae393c7e8fe8fce9d218875e8227b0187c04e7d2ea1fc", + }, + Vector { + ikm_e: "78628c354e46f3e169bd231be7b2ff1c77aa302460a26dbfa15515684c00130b", + pk_em: "0ad0950d9fb9588e59690b74f1237ecdf1d775cd60be2eca57af5a4b0471c91b", + sk_em: "463426a9ffb42bb17dbe6044b9abd1d4e4d95f9041cef0e99d7824eef2b6f588", + ikm_r: "d4a09d09f575fef425905d2ab396c1449141463f698f8efdb7accfaff8995098", + pk_rm: "9fed7e8c17387560e92cc6462a68049657246a09bfa8ade7aefe589672016366", + sk_rm: "c5eb01eb457fe6c6f57577c5413b931550a162c71a03ac8d196babbd4e5ce0fd", + enc: "0ad0950d9fb9588e59690b74f1237ecdf1d775cd60be2eca57af5a4b0471c91b", + shared_secret: "727699f009ffe3c076315019c69648366b69171439bd7dd0807743bde76986cd", + }, +]; + +fn decode_32(s: &str) -> [u8; 32] { + hex::decode(s).unwrap().try_into().unwrap() +} + +fn seed(s: &str) -> KeyMaterial256 { + KeyMaterial256::from_bytes_as_type(&decode_32(s), KeyType::Seed).unwrap() +} + +/// Clamps as `decodeScalar25519` does (RFC 7748 Section 5). +fn clamp(mut k: [u8; 32]) -> [u8; 32] { + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; + k +} + +/// `X25519::keygen` with its error mapped, in the shape the KEM test framework takes. +fn keygen() -> Result<(X25519PublicKey, X25519PrivateKey), KEMError> { + X25519::keygen().map_err(|_| KEMError::KeyGenError("X25519 keygen failed")) +} + +/// `Encap` with the vector's ephemeral key: the RNG emits skEm, which keygen uses as the private +/// key unchanged. +#[test] +fn rfc9180_encaps() { + for (i, v) in VECTORS.iter().enumerate() { + let pk_r = X25519PublicKey::from_bytes(decode_32(v.pk_rm)); + let mut rng = FixedSeedRNG::new(decode_32(v.sk_em)); + let (ss, enc) = X25519::encaps_rng(&pk_r, &mut rng).unwrap(); + assert_eq!(enc, decode_32(v.enc), "vector {i}: enc"); + assert_eq!(enc, decode_32(v.pk_em), "vector {i}: enc = pkEm"); + assert_eq!(ss.ref_to_bytes(), decode_32(v.shared_secret), "vector {i}: shared_secret"); + assert_eq!(ss.key_type(), KeyType::CryptographicRandom, "vector {i}"); + assert_eq!(ss.security_strength(), SecurityStrength::_128bit, "vector {i}"); + } +} + +#[test] +fn rfc9180_decaps() { + for (i, v) in VECTORS.iter().enumerate() { + let sk_r = X25519PrivateKey::from_bytes(&decode_32(v.sk_rm)).unwrap(); + assert_eq!(sk_r.pk().as_bytes(), &decode_32(v.pk_rm), "vector {i}: pk(skRm)"); + let ss = X25519::decaps(&sk_r, &decode_32(v.enc)).unwrap(); + assert_eq!(ss.ref_to_bytes(), decode_32(v.shared_secret), "vector {i}: shared_secret"); + assert_eq!(ss.key_type(), KeyType::CryptographicRandom, "vector {i}"); + assert_eq!(ss.security_strength(), SecurityStrength::_128bit, "vector {i}"); + } +} + +/// `DeriveKeyPair` (RFC 9180 Section 7.1.3) maps ikmE and ikmR to the vectors' key pairs. +/// +/// The vectors list skEm and skRm unclamped, while RFC 9180 Section 7.1.2 requires a serialized +/// private key to be clamped, so the encodings are compared against the clamped vector values. +#[test] +fn rfc9180_derive_key_pair() { + for (i, v) in VECTORS.iter().enumerate() { + for (ikm, pkm, skm) in [(v.ikm_e, v.pk_em, v.sk_em), (v.ikm_r, v.pk_rm, v.sk_rm)] { + let (pk, sk) = X25519DHKEM::derive_key_pair(&seed(ikm)).unwrap(); + assert_eq!(pk.encode(), decode_32(pkm), "vector {i}: pk from ikm {ikm}"); + assert_eq!(sk.encode(), clamp(decode_32(skm)), "vector {i}: sk from ikm {ikm}"); + } + } +} + +#[test] +fn derive_key_pair_rejects_bad_ikm() { + let ikm = VECTORS[0].ikm_e; + + let mac_key = KeyMaterial256::from_bytes_as_type(&decode_32(ikm), KeyType::MACKey).unwrap(); + match X25519DHKEM::derive_key_pair(&mac_key) { + Err(KEMError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError for the wrong key type"), + } + + let short = KeyMaterial256::from_bytes_as_type(&decode_32(ikm)[..31], KeyType::Seed).unwrap(); + match X25519DHKEM::derive_key_pair(&short) { + Err(KEMError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError for a 31-byte ikm"), + } + + let mut weak = seed(ikm); + weak.set_security_strength(SecurityStrength::_112bit).unwrap(); + match X25519DHKEM::derive_key_pair(&weak) { + Err(KEMError::KeyGenError(_)) => { /* good */ } + _ => panic!("expected KeyGenError for a 112-bit ikm"), + } + + // Exactly the algorithm's strength is enough. + let mut just_right = seed(ikm); + just_right.set_security_strength(SecurityStrength::_128bit).unwrap(); + X25519DHKEM::derive_key_pair(&just_right).unwrap(); + + // A longer ikm is allowed. + let long = KeyMaterial256::from_bytes_as_type(&[0x5A; 32], KeyType::Seed).unwrap(); + X25519DHKEM::derive_key_pair(&long).unwrap(); +} + +/// `encaps_rng` rejects an RNG below 128 bits and accepts one at exactly 128 bits. +#[test] +fn encaps_rng_security_strength() { + let (pk, _) = X25519::keygen().unwrap(); + + let mut weak = FixedSeedRNG::new([0x5A; 32]); + weak.set_security_strength(SecurityStrength::_112bit); + match X25519::encaps_rng(&pk, &mut weak) { + Err(KEMError::RNGError(_)) => { /* good */ } + _ => panic!("expected RNGError for a 112-bit RNG"), + } + + let mut just_right = FixedSeedRNG::new([0x5A; 32]); + just_right.set_security_strength(SecurityStrength::_128bit); + X25519::encaps_rng(&pk, &mut just_right).unwrap(); +} + +/// An RNG at full strength whose every request fails. +struct FailingRNG; + +impl RNG for FailingRNG { + fn add_seed_keymaterial(&mut self, _: &dyn KeyMaterialTrait) -> Result<(), RNGError> { + Ok(()) + } + fn next_int(&mut self) -> Result { + Err(RNGError::GenericError("failing RNG")) + } + fn next_bytes(&mut self, _: usize) -> Result, RNGError> { + Err(RNGError::GenericError("failing RNG")) + } + fn next_bytes_out(&mut self, _: &mut [u8]) -> Result { + Err(RNGError::GenericError("failing RNG")) + } + fn fill_keymaterial_out(&mut self, _: &mut dyn KeyMaterialTrait) -> Result { + Err(RNGError::GenericError("failing RNG")) + } + fn security_strength(&self) -> SecurityStrength { + SecurityStrength::_256bit + } +} + +/// An RNG failure while generating the ephemeral key pair is passed through as the RNG's error. +#[test] +fn encaps_rng_passes_through_rng_failure() { + let (pk, _) = X25519::keygen().unwrap(); + match X25519::encaps_rng(&pk, &mut FailingRNG) { + Err(KEMError::RNGError(RNGError::GenericError(_))) => { /* good */ } + other => panic!("expected the RNG's error, got {other:?}"), + } +} + +#[test] +fn kem_framework() { + // DHKEM is randomized. A modified ciphertext is another public key, so decaps succeeds with a + // different shared secret, which is the behaviour the framework checks for an implicitly + // rejecting KEM. Full bit-flipping is off: flipping bit 255 of enc gives the same shared + // secret, since X25519 ignores that bit (RFC 7748 Section 5). + TestFrameworkKEM::new(false, true).test_kem::< + X25519PublicKey, + X25519PrivateKey, + X25519, + POINT_SIZE, + SCALAR_SIZE, + POINT_SIZE, + DHKEM_SS_LEN, + >(keygen, false); +} + +#[test] +fn kem_keys_framework() { + TestFrameworkKEMKeys::new() + .test_keys::(keygen); +} + +/// Low-order public keys give an all-zero X25519 output, which RFC 9180 Section 7.1.4 requires +/// both sides to reject. +#[test] +fn rejects_low_order_public_key() { + let (_, sk) = X25519::keygen().unwrap(); + let mut zero_bit_255 = [0_u8; 32]; + zero_bit_255[31] = 0x80; + let p = decode_32("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"); + + for u in [[0_u8; 32], p, zero_bit_255] { + match X25519::encaps(&X25519PublicKey::from_bytes(u)) { + Err(KEMError::InvalidPublicKey(_)) => { /* good */ } + other => panic!("encaps: expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), + } + match X25519::decaps(&sk, &u) { + Err(KEMError::DecapsulationFailed) => { /* good */ } + other => panic!("decaps: expected DecapsulationFailed for u = {u:02x?}, got {other:?}"), + } + } +} + +/// RFC 9180 Section 7.1.2: SerializePrivateKey clamps its output and DeserializePrivateKey clamps +/// its input, and keys differing only in the clamped bits are the same key. +#[test] +fn private_key_encoding_is_clamped() { + let raw = decode_32(VECTORS[0].sk_rm); + assert_ne!(raw, clamp(raw), "the vector key must be unclamped for this test to mean anything"); + + let sk = X25519PrivateKey::from_bytes(&raw).unwrap(); + assert_eq!(sk.encode(), clamp(raw)); + let mut out = [0xFF_u8; 32]; + assert_eq!(sk.encode_out(&mut out), 32); + assert_eq!(out, clamp(raw)); + + // A key from keygen_from_seed keeps the unclamped bytes, but is equal to the clamped one. + let (_, unclamped) = X25519::keygen_from_seed(&seed(VECTORS[0].sk_rm)).unwrap(); + assert!(unclamped == sk); + assert_eq!(unclamped.encode(), clamp(raw)); + assert!(unclamped.clone() == sk); + + let (_, other) = X25519::keygen().unwrap(); + assert!(other != sk); +} + +#[test] +fn public_key_encoding() { + let bytes = decode_32(VECTORS[0].pk_rm); + let pk = >::from_bytes(&bytes).unwrap(); + assert_eq!(pk.encode(), bytes); + let mut out = [0xFF_u8; 32]; + assert_eq!(pk.encode_out(&mut out), 32); + assert_eq!(out, bytes); + assert_eq!( + pk.to_string(), + format!("X25519PublicKey {{ {} }}", VECTORS[0].pk_rm), + "Display shows the bytes in hex" + ); +} diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs index 075c39e0f..e3376a196 100644 --- a/crypto/ed/tests/x25519_key_tests.rs +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -1,7 +1,6 @@ use bouncycastle_core::errors::{KeyAgreementError, RNGError}; use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; use bouncycastle_core::security_strength::SecurityStrength; -use bouncycastle_core::traits::KeyAgreement; use bouncycastle_core_test_framework::FixedSeedRNG; use bouncycastle_ed::x25519::X25519; use bouncycastle_hex as hex; @@ -141,16 +140,3 @@ fn public_key_to_bytes() { let as_ref: &[u8] = pk.as_ref(); assert_eq!(as_ref, expected); } - -/// Two freshly generated key pairs agree on the same, non-zero shared secret. -#[test] -fn agreement() { - for i in 0..100 { - let (pk_a, sk_a) = X25519::keygen().unwrap(); - let (pk_b, sk_b) = X25519::keygen().unwrap(); - - let ss_a = X25519::key_agreement(&pk_b, sk_a).unwrap(); - let ss_b = X25519::key_agreement(&pk_a, sk_b).unwrap(); - assert_eq!(ss_a, ss_b, "ECDH #{i}"); - } -} diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 3e7267429..38662424a 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -62,8 +62,8 @@ fn rfc7748_section_6_1() { ); let k = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"; - let alice_k = X25519::key_agreement(&bob_pk, alice_sk).unwrap(); - let bob_k = X25519::key_agreement(&alice_pk, bob_sk).unwrap(); + let alice_k = X25519::key_agreement(&bob_pk, &alice_sk).unwrap(); + let bob_k = X25519::key_agreement(&alice_pk, &bob_sk).unwrap(); check_value(alice_k.as_bytes(), "Section 6.1 K (Alice)", k); check_value(bob_k.as_bytes(), "Section 6.1 K (Bob)", k); } @@ -82,7 +82,7 @@ fn key_agreement_rejects_all_zero_shared_secret() { let sk = sk_from_bytes(&decode_32( "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", )); - match X25519::key_agreement(&X25519PublicKey::from_bytes(u), sk) { + match X25519::key_agreement(&X25519PublicKey::from_bytes(u), &sk) { Err(KeyAgreementError::InvalidPublicKey(_)) => { /* good */ } other => panic!("expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), } @@ -114,7 +114,7 @@ fn rfc7748_check_iterated(count: usize) { u[0] = 9; let mut iterations = 0; while iterations < count { - let r = X25519::key_agreement(&X25519PublicKey::from_bytes(u), sk_from_bytes(&k)).unwrap(); + let r = X25519::key_agreement(&X25519PublicKey::from_bytes(u), &sk_from_bytes(&k)).unwrap(); u = k; k = *r.as_bytes(); @@ -151,6 +151,6 @@ fn check_vector(sk: &str, su: &str, se: &str, text: &str) { let k = sk_from_bytes(&decode_32(sk)); let u = X25519PublicKey::from_bytes(decode_32(su)); - let r = X25519::key_agreement(&u, k).unwrap(); + let r = X25519::key_agreement(&u, &k).unwrap(); check_value(r.as_bytes(), text, se); } diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index 428631a37..9e9dede98 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -44,7 +44,7 @@ fn wycheproof_x25519() { let shared = hex_field(test, "shared", tc_id); let (k, u) = (sk_from_bytes(&k), X25519PublicKey::from_bytes(u)); - match X25519::key_agreement(&u, k) { + match X25519::key_agreement(&u, &k) { Ok(r) => assert_eq!(r.as_bytes(), shared.as_slice(), "{ctx}: shared secret"), Err(KeyAgreementError::InvalidPublicKey(_)) => { assert!(shared.iter().all(|&b| b == 0), "{ctx}: rejected a non-zero secret"); diff --git a/crypto/hpke/Cargo.toml b/crypto/hpke/Cargo.toml new file mode 100644 index 000000000..c54b2ea89 --- /dev/null +++ b/crypto/hpke/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bouncycastle-hpke" +version.workspace = true +edition.workspace = true + +[dependencies] +bouncycastle-core.workspace = true +bouncycastle-hmac.workspace = true +bouncycastle-utils.workspace = true diff --git a/crypto/hpke/src/dhkem.rs b/crypto/hpke/src/dhkem.rs new file mode 100644 index 000000000..996c2735d --- /dev/null +++ b/crypto/hpke/src/dhkem.rs @@ -0,0 +1,300 @@ +//! The DH-Based KEM (DHKEM) of RFC 9180 Section 4.1, generic over the Diffie-Hellman group. +//! +//! A group's crate implements [`DHKEMParams`] for its algorithm struct and then calls +//! [`DHKEM::encap_rng`] and [`DHKEM::decap`] from its +//! [`KEMEncapsulator`](bouncycastle_core::traits::KEMEncapsulator) and +//! [`KEMDecapsulator`](bouncycastle_core::traits::KEMDecapsulator) impls. + +use bouncycastle_core::errors::{KEMError, KeyAgreementError, RNGError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::{Algorithm, Hash, HashAlgParams, MAC, RNG}; +use bouncycastle_hmac::HMAC; +use bouncycastle_utils::secret::Secret; +use core::marker::PhantomData; + +/// The version label that RFC 9180 Section 4 puts at the front of every labeled KDF input. +const HPKE_VERSION_LABEL: &[u8] = b"HPKE-v1"; + +// The labels of RFC 9180 Sections 4.1 and 7.1.3. +const LABEL_EAE_PRK: &[u8] = b"eae_prk"; +const LABEL_SHARED_SECRET: &[u8] = b"shared_secret"; +const LABEL_DKP_PRK: &[u8] = b"dkp_prk"; +const LABEL_SK: &[u8] = b"sk"; + +/// The largest Nh among the KDFs of RFC 9180 Section 7.2 (HKDF-SHA512). The PRK and HKDF-Expand +/// block buffers are sized to it. +const MAX_NH: usize = 64; + +/// The Diffie-Hellman group of a DHKEM, and the KDF it is paired with: DHKEM(Group, KDF) in RFC 9180 +/// Section 4.1. +/// +/// Implemented by a group's algorithm struct. The const parameters are the group's sizes from RFC +/// 9180 Section 7.1: `N_PK` is Npk (which equals Nenc), `N_SK` is Nsk and `N_DH` is Ndh. +pub trait DHKEMParams: Algorithm { + /// The group's public key. + type PublicKey; + /// The group's private key. + type PrivateKey; + /// The hash of the DHKEM's KDF, which is HKDF over this hash. + type KDFHash: Hash + HashAlgParams + Default; + /// The `kem_id` of RFC 9180 Section 7.1, Table 2. + const KEM_ID: u16; + + /// `GenerateKeyPair()` (RFC 9180 Section 4), drawing its randomness from `rng`. + fn generate_key_pair_rng( + rng: &mut dyn RNG, + ) -> Result<(Self::PublicKey, Self::PrivateKey), KEMError>; + + /// The private key with the byte string `sk` as its value, as `DeriveKeyPair` (RFC 9180 + /// Section 7.1.3) requires. + fn private_key_from_bytes(sk: &[u8; N_SK]) -> Self::PrivateKey; + + /// `pk(skX)` (RFC 9180 Section 4): the public key of `sk`. + fn pk(sk: &Self::PrivateKey) -> Self::PublicKey; + + /// `DH(skX, pkY)` (RFC 9180 Section 4.1), including the validation of its output that RFC 9180 + /// Section 7.1.4 requires for the group. A validation failure is an `Err`. + fn dh( + sk: &Self::PrivateKey, + pk: &Self::PublicKey, + ) -> Result, KeyAgreementError>; + + /// `SerializePublicKey(pkX)` (RFC 9180 Section 7.1.1). + fn serialize_public_key(pk: &Self::PublicKey) -> [u8; N_PK]; + + /// `DeserializePublicKey(pkXm)` (RFC 9180 Section 7.1.1). + fn deserialize_public_key(pkm: &[u8; N_PK]) -> Result; +} + +/// The DHKEM of RFC 9180 Section 4.1 over the group `G`, without the optional `AuthEncap` and +/// `AuthDecap`. +/// +/// The const parameters are `G`'s sizes (see [`DHKEMParams`]) plus `N_SECRET`, the Nsecret of RFC +/// 9180 Section 7.1, which must equal the output length of [`DHKEMParams::KDFHash`] (Section 4.1: +/// "the size Nsecret of the KEM shared secret is equal to the output length of the hash function +/// underlying the KDF"). +pub struct DHKEM +{ + _group: PhantomData, +} + +impl< + G: DHKEMParams, + const N_PK: usize, + const N_SK: usize, + const N_DH: usize, + const N_SECRET: usize, +> DHKEM +{ + /// `suite_id = concat("KEM", I2OSP(kem_id, 2))` (RFC 9180 Section 4.1). + const SUITE_ID: [u8; 5] = { + let kem_id = G::KEM_ID.to_be_bytes(); + [b'K', b'E', b'M', kem_id[0], kem_id[1]] + }; + + /// Checked when a DHKEM is instantiated, so a wrong set of const parameters fails to compile. + const PARAMS_CHECK: () = { + let nh = ::OUTPUT_LEN; + assert!(N_SECRET == nh, "Nsecret must equal the output length of the KDF's hash"); + assert!(nh <= MAX_NH, "the KDF's hash output is longer than MAX_NH"); + // HKDF-Expand's L, which here is Nsecret or Nsk, must be at most 255*HashLen (RFC 5869 + // Section 2.3), and fit the two bytes of I2OSP(L, 2) in LabeledExpand. + assert!(N_SK <= 255 * nh && N_SK <= 0xFFFF, "Nsk is too long for HKDF-Expand"); + }; + + /// `Encap(pkR)` (RFC 9180 Section 4.1), drawing the ephemeral key pair from `rng`. + /// + /// Returns the shared secret and `enc`, as a [`KeyType::CryptographicRandom`] key at the + /// group's security strength. + /// + /// # Errors + /// + /// * [`KEMError::RNGError`] if `rng`'s security strength is below the group's. + /// * [`KEMError::InvalidPublicKey`] if `pk_r` fails the group's validation (RFC 9180 Section + /// 7.1.4: "the sender MUST validate the recipient's public key pkR"). + /// * Any error from [`DHKEMParams::generate_key_pair_rng`]. + pub fn encap_rng( + pk_r: &G::PublicKey, + rng: &mut dyn RNG, + ) -> Result<(KeyMaterial, [u8; N_PK]), KEMError> { + let () = Self::PARAMS_CHECK; + + if rng.security_strength() < G::MAX_SECURITY_STRENGTH { + return Err(RNGError::SecurityStrengthInsufficientForAlgorithm)?; + } + + // skE, pkE = GenerateKeyPair() + let (pk_e, sk_e) = G::generate_key_pair_rng(rng)?; + // dh = DH(skE, pkR) + let dh = G::dh(&sk_e, pk_r).map_err(|_| { + KEMError::InvalidPublicKey("The recipient's public key failed validation") + })?; + // enc = SerializePublicKey(pkE) + let enc = G::serialize_public_key(&pk_e); + + // pkRm = SerializePublicKey(pkR) + let pk_rm = G::serialize_public_key(pk_r); + // kem_context = concat(enc, pkRm) + // shared_secret = ExtractAndExpand(dh, kem_context) + // Deviation from the RFC: kem_context is passed as its two parts rather than concatenated, + // since it is only ever fed to HMAC, which absorbs the parts in order. + let shared_secret = Self::extract_and_expand(&*dh, &[&enc, &pk_rm]); + // return shared_secret, enc + Ok((shared_secret, enc)) + } + + /// `Decap(enc, skR)` (RFC 9180 Section 4.1). + /// + /// Returns the shared secret, as a [`KeyType::CryptographicRandom`] key at the group's security + /// strength. + /// + /// # Errors + /// + /// [`KEMError::DecapsulationFailed`] if `enc` does not deserialize, or fails the group's + /// validation (RFC 9180 Section 7.1.4: "the recipient MUST validate the ephemeral public key + /// pkE"). + pub fn decap( + enc: &[u8; N_PK], + sk_r: &G::PrivateKey, + ) -> Result, KEMError> { + let () = Self::PARAMS_CHECK; + + // pkE = DeserializePublicKey(enc) + let pk_e = G::deserialize_public_key(enc).map_err(|_| KEMError::DecapsulationFailed)?; + // dh = DH(skR, pkE) + let dh = G::dh(sk_r, &pk_e).map_err(|_| KEMError::DecapsulationFailed)?; + + // pkRm = SerializePublicKey(pk(skR)) + let pk_rm = G::serialize_public_key(&G::pk(sk_r)); + // kem_context = concat(enc, pkRm) + // shared_secret = ExtractAndExpand(dh, kem_context) + // Deviation from the RFC: kem_context is passed in two parts; see encap_rng. + let shared_secret = Self::extract_and_expand(&*dh, &[enc, &pk_rm]); + // return shared_secret + Ok(shared_secret) + } + + /// `DeriveKeyPair(ikm)` (RFC 9180 Section 7.1.3), for groups whose private key is the byte string + /// itself, such as X25519 and X448. + /// + /// Section 7.1.3: "the ikm parameter given to DeriveKeyPair() SHOULD have length at least Nsk, + /// and SHOULD have at least Nsk bytes of entropy". This requires `ikm` to be at least Nsk bytes, + /// of [`KeyType::Seed`] or [`KeyType::CryptographicRandom`], and at least the group's security + /// strength. + /// + /// # Errors + /// + /// [`KEMError::KeyGenError`] if `ikm` does not meet those requirements. + pub fn derive_key_pair( + ikm: &impl KeyMaterialTrait, + ) -> Result<(G::PublicKey, G::PrivateKey), KEMError> { + let () = Self::PARAMS_CHECK; + + if !(ikm.key_type() == KeyType::Seed || ikm.key_type() == KeyType::CryptographicRandom) + || ikm.key_len() < N_SK + { + return Err(KEMError::KeyGenError( + "ikm must be at least Nsk bytes and KeyType::Seed or KeyType::CryptographicRandom.", + )); + } + if ikm.security_strength() < G::MAX_SECURITY_STRENGTH { + return Err(KEMError::KeyGenError( + "ikm SecurityStrength must match algorithm security strength", + )); + } + + // dkp_prk = LabeledExtract("", "dkp_prk", ikm) + let dkp_prk = Self::labeled_extract(LABEL_DKP_PRK, ikm.ref_to_bytes()); + // sk = LabeledExpand(dkp_prk, "sk", "", Nsk) + let mut sk = Secret::<[u8; N_SK]>::new(); + Self::labeled_expand(&dkp_prk, LABEL_SK, &[], &mut *sk); + let sk = G::private_key_from_bytes(&sk); + // return (sk, pk(sk)) + Ok((G::pk(&sk), sk)) + } + + /// `ExtractAndExpand(dh, kem_context)` (RFC 9180 Section 4.1), with `kem_context` given as the + /// parts it is the concatenation of. + /// + /// The output is labelled as a full-entropy key at the group's strength: it is HKDF output, and + /// so pseudorandom, unlike the DH output it is derived from. + fn extract_and_expand(dh: &[u8], kem_context: &[&[u8]]) -> KeyMaterial { + // eae_prk = LabeledExtract("", "eae_prk", dh) + let eae_prk = Self::labeled_extract(LABEL_EAE_PRK, dh); + // shared_secret = LabeledExpand(eae_prk, "shared_secret", kem_context, Nsecret) + let mut shared_secret = Secret::<[u8; N_SECRET]>::new(); + Self::labeled_expand(&eae_prk, LABEL_SHARED_SECRET, kem_context, &mut *shared_secret); + + // Cannot fail: N_SECRET bytes fit a KeyMaterial, and an all-zero output gives + // Ok(Zeroized). + let mut key = + KeyMaterial::from_bytes_as_type(&*shared_secret, KeyType::CryptographicRandom).unwrap(); + if key.key_type() == KeyType::CryptographicRandom { + // Cannot fail: N_SECRET >= 32 bytes is enough for any strength, and the inferred + // strength (from_bytes_as_type sets it from the length) is lowered, never raised. + key.set_security_strength(G::MAX_SECURITY_STRENGTH).unwrap(); + } + key + } + + /// `LabeledExtract("", label, ikm)` (RFC 9180 Section 4), where `Extract` is HKDF-Extract + /// (RFC 5869 Section 2.2) over [`DHKEMParams::KDFHash`]. Every call in a DHKEM has an empty salt. + /// + /// Returns the PRK as a MAC key, ready for [`Self::labeled_expand`]. + fn labeled_extract(label: &[u8], ikm: &[u8]) -> KeyMaterial { + // PRK = HMAC-Hash(salt, IKM) + // An empty salt is an empty HMAC key. RFC 5869 Section 2.2 says an absent salt "is set to a + // string of HashLen zeros", which is the same HMAC key, as HMAC zero-pads its key. + // Cannot fail: an empty KeyMaterial is Zeroized, which HMAC accepts as a weak key. + let mut hmac = HMAC::::new_allow_weak_key(&KeyMaterial::<0>::new()).unwrap(); + // labeled_ikm = concat("HPKE-v1", suite_id, label, ikm) + // Deviation from the RFC: the parts of labeled_ikm are absorbed by HMAC in order rather than + // concatenated first. + hmac.do_update(HPKE_VERSION_LABEL); + hmac.do_update(&Self::SUITE_ID); + hmac.do_update(label); + hmac.do_update(ikm); + + let mut prk = Secret::<[u8; MAX_NH]>::new(); + // Cannot fail: the buffer is at least the hash output length (PARAMS_CHECK). + let nh = hmac.do_final_out(&mut *prk).unwrap(); + // Cannot fail: nh <= MAX_NH bytes fit a KeyMaterial. + KeyMaterial::from_bytes_as_type(&prk[..nh], KeyType::MACKey).unwrap() + } + + /// `LabeledExpand(prk, label, info, L)` (RFC 9180 Section 4), where `Expand` is HKDF-Expand (RFC + /// 5869 Section 2.3) over [`DHKEMParams::KDFHash`], `L` is `out.len()`, and `info` is given as + /// the parts it is the concatenation of. + fn labeled_expand(prk: &KeyMaterial, label: &[u8], info: &[&[u8]], out: &mut [u8]) { + let nh = ::OUTPUT_LEN; + // L is Nsecret or Nsk, which PARAMS_CHECK bounds by 255*HashLen and by 0xFFFF. + debug_assert!(out.len() <= 255 * nh && out.len() <= 0xFFFF); + let l = (out.len() as u16).to_be_bytes(); + + // T(0) = empty string (zero length) + let mut t = Secret::<[u8; MAX_NH]>::new(); + let mut t_len = 0; + // N = ceil(L/HashLen); T = T(1) | T(2) | ... | T(N); OKM = first L octets of T + // Each chunk of `out` is one T(i), the last one truncated. + for (i, okm_chunk) in out.chunks_mut(nh).enumerate() { + // T(i) = HMAC-Hash(PRK, T(i-1) | info | i), with info = labeled_info + // Cannot fail: the PRK is a MAC key of the hash's full output length. + let mut hmac = HMAC::::new(prk).unwrap(); + hmac.do_update(&t[..t_len]); + // labeled_info = concat(I2OSP(L, 2), "HPKE-v1", suite_id, label, info) + // Deviation from the RFC: absorbed in parts, as in labeled_extract. + hmac.do_update(&l); + hmac.do_update(HPKE_VERSION_LABEL); + hmac.do_update(&Self::SUITE_ID); + hmac.do_update(label); + for part in info { + hmac.do_update(part); + } + // i <= N <= 255, since L <= 255*HashLen. + hmac.do_update(&[(i + 1) as u8]); + // Cannot fail: the buffer is at least the hash output length (PARAMS_CHECK). + t_len = hmac.do_final_out(&mut *t).unwrap(); + okm_chunk.copy_from_slice(&t[..okm_chunk.len()]); + } + } +} diff --git a/crypto/hpke/src/lib.rs b/crypto/hpke/src/lib.rs new file mode 100644 index 000000000..95a263872 --- /dev/null +++ b/crypto/hpke/src/lib.rs @@ -0,0 +1,37 @@ +//! Building blocks of Hybrid Public Key Encryption (HPKE), as specified in RFC 9180. +//! +//! This is a utility crate and is not intended to be used directly. It provides +//! [`dhkem::DHKEM`] -- the DH-Based KEM of RFC 9180 Section 4.1, generic over any Diffie-Hellman +//! group that implements [`dhkem::DHKEMParams`], the extension point through which a group +//! declares its keys, its DH operation and the KDF that the DHKEM pairs it with. Each group's crate +//! implements that trait and exposes the result through the +//! [`KEMEncapsulator`](bouncycastle_core::traits::KEMEncapsulator) and +//! [`KEMDecapsulator`](bouncycastle_core::traits::KEMDecapsulator) traits on its own algorithm +//! struct +//! +//! For example: +//! +//! | KEM | kem_id | Exposed as | +//! |------------------------------|--------|--------------------------------| +//! | DHKEM(X25519, HKDF-SHA256) | 0x0020 | `bouncycastle_ed::x25519::X25519` | +//! +//! The KDF is HKDF over the hash named by [`dhkem::DHKEMParams::KDFHash`], computed on +//! [`HMAC`](bouncycastle_hmac::HMAC) as RFC 5869 defines it, so any hash that +//! [`HMAC`](bouncycastle_hmac::HMAC) supports can be used. +//! +//! The rest of HPKE (the key schedule, AEAD encryption and secret export of RFC 9180 Section 5) is +//! not yet implemented. +//! +//! # 🚨 Security Considerations 🚨 +//! +//! * [`dhkem::DHKEMParams::dh`] must perform the group's validation of the DH output (RFC 9180 +//! Section 7.1.4), such as rejecting the all-zero X25519 output. [`dhkem::DHKEM`] relies on it to +//! reject unusable public keys. +//! * [`dhkem::DHKEM::derive_key_pair`] is deterministic: the key pair is only as secret as the +//! input keying material. + +#![no_std] +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +pub mod dhkem; diff --git a/src/lib.rs b/src/lib.rs index 2b2a0fedf..5d5fc328a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub use bouncycastle_factory as factory; pub use bouncycastle_hex as hex; pub use bouncycastle_hkdf as hkdf; pub use bouncycastle_hmac as hmac; +pub use bouncycastle_hpke as hpke; pub use bouncycastle_mldsa as mldsa; pub use bouncycastle_mldsa_lowmemory as mldsa_lowmemory; pub use bouncycastle_mlkem as mlkem; From 313ca1074e93caea7b1ba02f7b3657680b0049d0 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 12:28:34 -0500 Subject: [PATCH 16/26] Added key encoding / decoding routines for x25519. --- crypto/core/src/errors.rs | 2 + crypto/core/src/traits.rs | 28 ++++++++- crypto/ed/ed_todo.md | 10 ++-- crypto/ed/src/x25519.rs | 92 +++++++++++++++++++++-------- crypto/ed/tests/x25519_key_tests.rs | 71 +++++++++++++++++++++- 5 files changed, 168 insertions(+), 35 deletions(-) diff --git a/crypto/core/src/errors.rs b/crypto/core/src/errors.rs index dc1cdfca2..9499f1651 100644 --- a/crypto/core/src/errors.rs +++ b/crypto/core/src/errors.rs @@ -93,6 +93,8 @@ pub enum KeyAgreementError { GenericError(&'static str), /// ConsistencyCheckFailed(&'static str), + /// A key encoding could not be decoded, for example because it is the wrong length. + DecodingError(&'static str), /// The peer's public key is unusable for key agreement, for example a point of small order. InvalidPublicKey(&'static str), /// diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 27fb1fb65..e5dd82a97 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1087,7 +1087,13 @@ pub trait KEMPublicKey: /// A Key Agreement algorithm, often called a Diffie-Hellman algorithm. /// The core function is `key_agreement(&pk, &sk)`, which combines one party's public key with the /// other party's private key, and returns the shared secret as a value of the public key type `PK`. -pub trait KeyAgreement: Sized { +pub trait KeyAgreement< + PK: KeyAgreementPublicKey, + SK: KeyAgreementPrivateKey, + const PK_LEN: usize, + const SK_LEN: usize, +>: Sized +{ /// Perform a key agreement. /// Note that this returns a public key. The caller is responsible for using a [`KDF`] to /// derive a shared symmetric key from it. @@ -1100,10 +1106,26 @@ pub trait KeyAgreement: S /// The bound on `AsRef<[u8]>` is because a Diffie-Hellman key agreement returns a public key as /// the shared secret value, so it must be easy to convert this to bytes in a cannonical representation /// so that it can be fed into a [`KDF`]. -pub trait KeyAgreementPublicKey: Sized + AsRef<[u8]> {} +pub trait KeyAgreementPublicKey: Sized + AsRef<[u8]> { + /// Write it out to bytes in its standard encoding. + fn encode(&self) -> [u8; PK_LEN]; + /// Write it out to bytes in its standard encoding. + /// The entire output buffer is zeroized before the encoding is written. + fn encode_out(&self, out: &mut [u8; PK_LEN]) -> usize; + /// Read it in from bytes in its standard encoding. + fn from_bytes(bytes: &[u8]) -> Result; +} /// A private key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "sk" (for "secret key"). -pub trait KeyAgreementPrivateKey: Sized {} +pub trait KeyAgreementPrivateKey: Sized { + /// Write it out to bytes in its standard encoding. + fn encode(&self) -> [u8; SK_LEN]; + /// Write it out to bytes in its standard encoding. + /// The entire output buffer is zeroized before the encoding is written. + fn encode_out(&self, out: &mut [u8; SK_LEN]) -> usize; + /// Read it in from bytes in its standard encoding. + fn from_bytes(bytes: &[u8]) -> Result; +} /// A Message Authentication Code algorithm is a keyed hash function that behaves somewhat like a symmetric signature function. /// A MAC algorithm takes in a key and some data, and produces a MAC (message authentication code) that diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index b2f7cf027..971d56160 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -46,12 +46,10 @@ Within each, the priority order is: ## Phase 2: Traits, Bells & Whistles -- [ ] Finish the `KeyAgreement` / `KeyAgreementPublicKey` / `KeyAgreementPrivateKey` traits in +- [x] Finish the `KeyAgreement` / `KeyAgreementPublicKey` / `KeyAgreementPrivateKey` traits in `traits.rs`. They need: - - [ ] key `from_bytes`/encoding, by analogy with `KEMPublicKey`/`KEMPrivateKey` (lengths as - const generics). Partly there: `KeyAgreementPublicKey` requires `AsRef<[u8]>`, and the X25519 - keys get `from_bytes`/`encode` from `KEMPublicKey`/`KEMPrivateKey`, but the `KeyAgreement*` - traits themselves have no decoding. + - [x] key `from_bytes`/encoding, by analogy with `KEMPublicKey`/`KEMPrivateKey` (lengths as + const generics). The X25519 `KEMPublicKey`/`KEMPrivateKey` impls delegate to these. - [x] the output type. Decided: `key_agreement` returns a `PK`, and the caller applies a KDF; the KEM traits are the API that hands back a `KeyMaterial` shared secret. - [x] an error type: `KeyAgreementError`. @@ -68,7 +66,7 @@ Within each, the priority order is: rejects it with `KeyAgreementError::InvalidPublicKey`. RFC 7748 §6.1 makes the check a MAY; RFC 8446 §7.4.2 and RFC 9180 §7.1.4 make it a MUST. - [ ] Open: detect a small-order public key before the private-key operation, rather than - from the output. + from the output. Is this even possible? Possible in constant-time? - [x] A `KEM` impl over X25519: `KEMEncapsulator`/`KEMDecapsulator` on `X25519` as DHKEM (X25519, HKDF-SHA256) (RFC 9180 §4.1), built on the generic `DHKEM` in the new `bouncycastle-hpke` utility crate. Tested against RFC 9180 A.1.1/A.1.2 and the core-test-framework KEM suites. diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 18f8a8b6d..b9352b349 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -292,7 +292,7 @@ impl X25519 { } } -impl KeyAgreement for X25519 { +impl KeyAgreement for X25519 { /// The X25519 Diffie-Hellman of RFC 7748 §6.1: computes the shared secret /// `K = X25519(sk, pk)`, the u-coordinate of the scalar multiple of the peer's public key by /// our private key, as its canonical 32-byte little-endian encoding. @@ -388,7 +388,35 @@ impl X25519PrivateKey { } } -impl KeyAgreementPrivateKey for X25519PrivateKey {} +/// The encoding is the 32 key bytes, clamped (the bit operations of `decodeScalar25519`, RFC 7748 +/// §5), so keys that compare equal have the same encoding. This is also the DHKEM serialization: +/// RFC 9180 §7.1.2 says "The SerializePrivateKey() function MUST clamp its output and the +/// DeserializePrivateKey() function MUST clamp its input". +impl KeyAgreementPrivateKey for X25519PrivateKey { + fn encode(&self) -> [u8; SCALAR_SIZE] { + *self.clamped() + } + + fn encode_out(&self, out: &mut [u8; SCALAR_SIZE]) -> usize { + out.fill(0); + *out = *self.clamped(); + SCALAR_SIZE + } + + /// Any 32 bytes are a valid private key; they are clamped on the way in. + /// + /// # Errors + /// + /// [`KeyAgreementError::DecodingError`] if `bytes` is not 32 bytes. + fn from_bytes(bytes: &[u8]) -> Result { + let bytes: &[u8; SCALAR_SIZE] = bytes.try_into().map_err(|_| { + KeyAgreementError::DecodingError("An X25519 private key must be 32 bytes") + })?; + let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + *k = *bytes; + Ok(Self(X25519PrivateKey(k).clamped())) + } +} /// Constant-time comparison of the clamped scalars. impl PartialEq for X25519PrivateKey { @@ -428,7 +456,31 @@ impl X25519PublicKey { } } -impl KeyAgreementPublicKey for X25519PublicKey {} +/// The encoding is the 32 bytes of the u-coordinate, as is (RFC 7748 §5). +impl KeyAgreementPublicKey for X25519PublicKey { + fn encode(&self) -> [u8; POINT_SIZE] { + self.0 + } + + fn encode_out(&self, out: &mut [u8; POINT_SIZE]) -> usize { + out.fill(0); + *out = self.0; + POINT_SIZE + } + + /// Any 32 bytes are accepted: RFC 7748 §5 requires bit 255 to be ignored and values `>= p` to + /// be processed as if reduced mod p. + /// + /// # Errors + /// + /// [`KeyAgreementError::DecodingError`] if `bytes` is not 32 bytes. + fn from_bytes(bytes: &[u8]) -> Result { + let bytes: &[u8; POINT_SIZE] = bytes.try_into().map_err(|_| { + KeyAgreementError::DecodingError("An X25519 public key must be 32 bytes") + })?; + Ok(Self(*bytes)) + } +} impl AsRef<[u8]> for X25519PublicKey { fn as_ref(&self) -> &[u8] { @@ -794,52 +846,42 @@ impl KEMDecapsulator fo } } -/// The encoding is the 32 key bytes, clamped: RFC 9180 §7.1.2 says "The SerializePrivateKey() -/// function MUST clamp its output and the DeserializePrivateKey() function MUST clamp its input". +/// The same clamped encoding as the [`KeyAgreementPrivateKey`] impl, which RFC 9180 §7.1.2 requires +/// of a DHKEM. impl KEMPrivateKey for X25519PrivateKey { fn encode(&self) -> [u8; SCALAR_SIZE] { - *self.clamped() + >::encode(self) } fn encode_out(&self, out: &mut [u8; SCALAR_SIZE]) -> usize { - out.fill(0); - *out = *self.clamped(); - SCALAR_SIZE + >::encode_out(self, out) } /// # Errors /// /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - let bytes: &[u8; SCALAR_SIZE] = bytes - .try_into() - .map_err(|_| KEMError::DecodingError("An X25519 private key must be 32 bytes"))?; - let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); - *k = *bytes; - Ok(Self(X25519PrivateKey(k).clamped())) + >::from_bytes(bytes) + .map_err(|_| KEMError::DecodingError("An X25519 private key must be 32 bytes")) } } -/// The encoding is the 32 bytes of the u-coordinate, as is: RFC 9180 §7.1.1 makes serialization -/// the identity function for X25519. +/// The same encoding as the [`KeyAgreementPublicKey`] impl: RFC 9180 §7.1.1 makes serialization the +/// identity function for X25519. impl KEMPublicKey for X25519PublicKey { fn encode(&self) -> [u8; POINT_SIZE] { - self.0 + >::encode(self) } fn encode_out(&self, out: &mut [u8; POINT_SIZE]) -> usize { - out.fill(0); - *out = self.0; - POINT_SIZE + >::encode_out(self, out) } /// # Errors /// /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - let bytes: &[u8; POINT_SIZE] = bytes - .try_into() - .map_err(|_| KEMError::DecodingError("An X25519 public key must be 32 bytes"))?; - Ok(Self(*bytes)) + >::from_bytes(bytes) + .map_err(|_| KEMError::DecodingError("An X25519 public key must be 32 bytes")) } } diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs index e3376a196..b303d9267 100644 --- a/crypto/ed/tests/x25519_key_tests.rs +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -1,8 +1,9 @@ use bouncycastle_core::errors::{KeyAgreementError, RNGError}; use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; use bouncycastle_core::security_strength::SecurityStrength; +use bouncycastle_core::traits::{KeyAgreementPrivateKey, KeyAgreementPublicKey}; use bouncycastle_core_test_framework::FixedSeedRNG; -use bouncycastle_ed::x25519::X25519; +use bouncycastle_ed::x25519::{X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; /// RFC 7748 Section 6.1: Alice's private key a and public key X25519(a, 9). @@ -140,3 +141,71 @@ fn public_key_to_bytes() { let as_ref: &[u8] = pk.as_ref(); assert_eq!(as_ref, expected); } + +fn decode_32(s: &str) -> [u8; 32] { + hex::decode(s).unwrap().try_into().unwrap() +} + +/// Clamps as `decodeScalar25519` does (RFC 7748 Section 5). +fn clamp(mut k: [u8; 32]) -> [u8; 32] { + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; + k +} + +#[test] +fn public_key_encoding() { + let bytes = decode_32(ALICE_PK); + let pk = X25519PublicKey::from_bytes(bytes); + assert_eq!(KeyAgreementPublicKey::encode(&pk), bytes); + + let mut out = [0xFF_u8; 32]; + assert_eq!(KeyAgreementPublicKey::encode_out(&pk, &mut out), 32); + assert_eq!(out, bytes); + + // Decoding round-trips, and accepts any 32 bytes, including bit 255 set (RFC 7748 Section 5). + let decoded = >::from_bytes(&bytes).unwrap(); + assert_eq!(decoded, pk); + let top_bit = [0xFF_u8; 32]; + let decoded = >::from_bytes(&top_bit).unwrap(); + assert_eq!(KeyAgreementPublicKey::encode(&decoded), top_bit); + + for len in [0, 31, 33] { + match >::from_bytes(&vec![1_u8; len]) { + Err(KeyAgreementError::DecodingError(_)) => { /* good */ } + _ => panic!("expected DecodingError for {len} bytes"), + } + } +} + +#[test] +fn private_key_encoding() { + // The RFC 7748 Section 6.1 key is unclamped, so this exercises the clamping. + let raw = decode_32(ALICE_SK); + assert_ne!(raw, clamp(raw)); + + let sk = >::from_bytes(&raw).unwrap(); + assert_eq!(KeyAgreementPrivateKey::encode(&sk), clamp(raw)); + assert_eq!(sk.pk().as_bytes(), &decode_32(ALICE_PK)); + + let mut out = [0xFF_u8; 32]; + assert_eq!(KeyAgreementPrivateKey::encode_out(&sk, &mut out), 32); + assert_eq!(out, clamp(raw)); + + // A key built from the unclamped seed is the same key, with the same encoding, and it + // round-trips through its encoding. + let (_, from_seed) = X25519::keygen_from_seed(&seed(ALICE_SK, KeyType::Seed)).unwrap(); + assert!(from_seed == sk); + assert_eq!(KeyAgreementPrivateKey::encode(&from_seed), clamp(raw)); + let encoded = KeyAgreementPrivateKey::encode(&from_seed); + let decoded = >::from_bytes(&encoded).unwrap(); + assert!(decoded == from_seed); + + for len in [0, 31, 33] { + match >::from_bytes(&vec![1_u8; len]) { + Err(KeyAgreementError::DecodingError(_)) => { /* good */ } + _ => panic!("expected DecodingError for {len} bytes"), + } + } +} From 87f0ea1780c72219e943aeb161d511afe98fdeef Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 14:10:38 -0500 Subject: [PATCH 17/26] Added core-test-framework/key_agreement.rs --- .../core-test-framework/src/key_agreement.rs | 144 ++++++++++++++++++ crypto/core-test-framework/src/lib.rs | 1 + crypto/core/src/traits.rs | 4 +- crypto/ed/ed_todo.md | 5 +- crypto/ed/tests/x25519_key_tests.rs | 11 +- crypto/ed/tests/x25519_tests.rs | 15 ++ 6 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 crypto/core-test-framework/src/key_agreement.rs diff --git a/crypto/core-test-framework/src/key_agreement.rs b/crypto/core-test-framework/src/key_agreement.rs new file mode 100644 index 000000000..d4063279e --- /dev/null +++ b/crypto/core-test-framework/src/key_agreement.rs @@ -0,0 +1,144 @@ +//! Generic behaviour tests for anything that implements [`KeyAgreement`]. + +use bouncycastle_core::errors::KeyAgreementError; +use bouncycastle_core::traits::{KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey}; + +/// Instance of the test framework. +pub struct TestFrameworkKeyAgreement { + // Put any config options here +} + +impl TestFrameworkKeyAgreement { + /// + pub fn new() -> Self { + Self {} + } + + /// Test the members of trait [`KeyAgreement`]. + /// + /// Since key generation is not part of the trait, the caller supplies a + /// `keygen` function pointer (the inherent `keygen` associated function on the algorithm struct). + pub fn test_key_agreement< + PK: KeyAgreementPublicKey, + SK: KeyAgreementPrivateKey, + KAAlg: KeyAgreement, + const PK_LEN: usize, + const SK_LEN: usize, + >( + &self, + keygen: fn() -> Result<(PK, SK), KeyAgreementError>, + ) { + let (pk_a, sk_a) = keygen().unwrap(); + let (pk_b, sk_b) = keygen().unwrap(); + + // Symmetry test -- basic correctness + // KA(&pk_a, &sk_b) == KA(&pk_b, &sk_a) + let k_ab = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); + let k_ba = KAAlg::key_agreement(&pk_b, &sk_a).unwrap(); + assert!(k_ba.eq(&k_ab)); + + // Determinism + // The same (pk, sk) twice gives the same output + let k_1 = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); + let k_2 = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); + assert!(k_1.eq(&k_2)); + + // Output is a usable PK + let k = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); + _ = KAAlg::key_agreement(&k, &sk_a).unwrap(); + + // Decoded keys behave the same + // KA on PK::from_bytes(&pk.encode()) and SK::from_bytes(&sk.encode()) equals KA on the originals + let k = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); + let k1 = KAAlg::key_agreement(&PK::from_bytes(&pk_a.encode()).unwrap(), &sk_b).unwrap(); + assert!(k.eq(&k1)); + let k2 = KAAlg::key_agreement(&pk_a, &SK::from_bytes(&sk_b.encode()).unwrap()).unwrap(); + assert!(k.eq(&k2)); + } +} + +/// Instance of the test framework. +pub struct TestFrameworkKeyAgreementKeys {} + +impl TestFrameworkKeyAgreementKeys { + /// + pub fn new() -> Self { + Self {} + } + + /// Test the members of traits [`KeyAgreementPublicKey`] and [`KeyAgreementPrivateKey`]. + /// + /// Since key generation is not part of either key trait, the caller supplies a + /// `keygen` function pointer (the inherent `keygen` associated function on the algorithm struct). + pub fn test_keys< + PK: KeyAgreementPublicKey, + SK: KeyAgreementPrivateKey, + const PK_LEN: usize, + const SK_LEN: usize, + >( + &self, + keygen: fn() -> Result<(PK, SK), KeyAgreementError>, + ) { + let (pk, sk) = keygen().unwrap(); + + // Check encoded lengths + assert_eq!(pk.encode().len(), PK_LEN); + assert_eq!(sk.encode().len(), SK_LEN); + + // AsRef matches encode + assert_eq!(pk.as_ref(), &pk.encode()); + + // encode_out matches encode + let mut pk_buf = [0u8; PK_LEN]; + let bytes_written = pk.encode_out(&mut pk_buf); + assert_eq!(bytes_written, PK_LEN); + assert_eq!(pk_buf, pk.encode()); + + let mut sk_buf = [0u8; SK_LEN]; + let bytes_written = sk.encode_out(&mut sk_buf); + assert_eq!(bytes_written, SK_LEN); + assert_eq!(sk_buf, sk.encode()); + + // encode_out overwrites + let mut pk_buf = [0xff; PK_LEN]; + let bytes_written = pk.encode_out(&mut pk_buf); + assert_eq!(bytes_written, PK_LEN); + assert_eq!(pk_buf, pk.encode()); + + let mut sk_buf = [0xff; SK_LEN]; + let bytes_written = sk.encode_out(&mut sk_buf); + assert_eq!(bytes_written, SK_LEN); + assert_eq!(sk_buf, sk.encode()); + + // Encoding round trips + assert_eq!(PK::from_bytes(&pk.encode()).unwrap().encode(), pk.encode()); + assert_eq!(SK::from_bytes(&sk.encode()).unwrap().encode(), sk.encode()); + + // Wrong length throws Err(DecodingError(_)) + // pk too small + assert!(matches!( + PK::from_bytes(&pk.encode()[..PK_LEN - 1]), + Err(KeyAgreementError::DecodingError(_)) + )); + // sk too large + let mut pk_buf_too_large = [0xff; 1000]; + pk_buf_too_large[..PK_LEN].copy_from_slice(&pk.encode()); + assert!(matches!( + PK::from_bytes(&pk_buf_too_large), + Err(KeyAgreementError::DecodingError(_)) + )); + + // sk too small + assert!(matches!( + SK::from_bytes(&sk.encode()[..SK_LEN - 1]), + Err(KeyAgreementError::DecodingError(_)) + )); + // sk too large + let mut sk_buf_too_large = [0xff; 1000]; + sk_buf_too_large[..SK_LEN].copy_from_slice(&sk.encode()); + assert!(matches!( + SK::from_bytes(&sk_buf_too_large), + Err(KeyAgreementError::DecodingError(_)) + )); + } +} diff --git a/crypto/core-test-framework/src/lib.rs b/crypto/core-test-framework/src/lib.rs index 5f7ffb288..d5ed30afc 100644 --- a/crypto/core-test-framework/src/lib.rs +++ b/crypto/core-test-framework/src/lib.rs @@ -20,6 +20,7 @@ pub mod electronic_code_book; pub mod hash; pub mod kdf; pub mod kem; +pub mod key_agreement; pub mod key_stream; pub mod mac; pub mod signature; diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index e5dd82a97..938201aab 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1106,7 +1106,9 @@ pub trait KeyAgreement< /// The bound on `AsRef<[u8]>` is because a Diffie-Hellman key agreement returns a public key as /// the shared secret value, so it must be easy to convert this to bytes in a cannonical representation /// so that it can be fed into a [`KDF`]. -pub trait KeyAgreementPublicKey: Sized + AsRef<[u8]> { +pub trait KeyAgreementPublicKey: + PartialEq + Eq + Clone + Debug + Display + Sized + AsRef<[u8]> +{ /// Write it out to bytes in its standard encoding. fn encode(&self) -> [u8; PK_LEN]; /// Write it out to bytes in its standard encoding. diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 971d56160..da6c56a1b 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -60,7 +60,7 @@ Within each, the priority order is: - [x] Implement the traits for X25519. Move the Wycheproof and RFC suites onto the trait API. - [x] Re-enable the `agreement` round-trip test. Later removed as redundant: the RFC 7748 iterated and Wycheproof known answers, and the KEM framework round trip, cover it. -- [ ] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from +- [x] Add a shared `KeyAgreement` conformance suite in `core-test-framework` and invoke it from X25519. - [x] Decide what happens on an all-zero shared secret (low-order public key): `key_agreement` rejects it with `KeyAgreementError::InvalidPublicKey`. RFC 7748 §6.1 makes the check a MAY; @@ -75,8 +75,7 @@ Within each, the priority order is: - [x] `keygen_from_seed`, from a 32-byte `KeyMaterial256`; - [x] `keypair_consistency_check`. No `keygen_from_seed_and_encoded`: the seed *is* the private key encoding, so there is nothing to compare. -- [ ] Wire into the factory (a `KeyAgreementFactory`, if that fits the factory pattern) and add a - CLI subcommand (`cli/src/*_cmd.rs`). +- [ ] Wire into the cli (we are going to skip Factory) - [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). Scoped runs done on the KEM code (one survivor: an error-mapping arm unreachable with a well-behaved RNG) and on `bouncycastle-hpke`; a full-crate run is still to do. diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs index b303d9267..304ec1f6e 100644 --- a/crypto/ed/tests/x25519_key_tests.rs +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -3,7 +3,7 @@ use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType} use bouncycastle_core::security_strength::SecurityStrength; use bouncycastle_core::traits::{KeyAgreementPrivateKey, KeyAgreementPublicKey}; use bouncycastle_core_test_framework::FixedSeedRNG; -use bouncycastle_ed::x25519::{X25519, X25519PrivateKey, X25519PublicKey}; +use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; /// RFC 7748 Section 6.1: Alice's private key a and public key X25519(a, 9). @@ -14,6 +14,15 @@ fn seed(hex_str: &str, key_type: KeyType) -> KeyMaterial256 { KeyMaterial256::from_bytes_as_type(&hex::decode(hex_str).unwrap(), key_type).unwrap() } +#[test] +fn core_framework_tests() { + use bouncycastle_core_test_framework::key_agreement::TestFrameworkKeyAgreementKeys; + + let tf = TestFrameworkKeyAgreementKeys::new(); + + tf.test_keys::(X25519::keygen); +} + #[test] fn keygen_from_seed() { for key_type in [KeyType::Seed, KeyType::CryptographicRandom] { diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 38662424a..bfe196ae5 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -4,11 +4,26 @@ use bouncycastle_core::traits::KeyAgreement; use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; +#[test] +fn core_framework_tests() { + use bouncycastle_core_test_framework::key_agreement::TestFrameworkKeyAgreement; + + let tf = TestFrameworkKeyAgreement::new(); + + tf.test_key_agreement::( + X25519::keygen, + ); +} + #[test] fn iterated() { rfc7748_check_iterated(1000); } +/// Ignored because it's quite slow, can be run with +/// cargo test -p bouncycastle-ed -- --ignored +/// or +/// cargo test -p bouncycastle-ed -- --include-ignored #[ignore] #[test] fn iterated_full() { From 5e54dde2f16fd7dabf9551f2b0d72f155332db95 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 14:48:40 -0500 Subject: [PATCH 18/26] Added a cli for x25519 --- cli/src/main.rs | 26 ++++ cli/src/x25519_cmd.rs | 160 ++++++++++++++++++++++ cli/tests/test_x25519.sh | 185 ++++++++++++++++++++++++++ crypto/ed/ed_todo.md | 8 +- crypto/ed/src/x25519.rs | 5 - crypto/ed/tests/x25519_dhkem_tests.rs | 4 +- crypto/ed/tests/x25519_key_tests.rs | 2 +- crypto/ed/tests/x25519_tests.rs | 10 +- crypto/ed/tests/x25519_wycheproof.rs | 4 +- 9 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 cli/src/x25519_cmd.rs create mode 100755 cli/tests/test_x25519.sh diff --git a/cli/src/main.rs b/cli/src/main.rs index d4872f25d..e445355ea 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -16,6 +16,7 @@ mod rng_cmd; mod sha2_cmd; mod sha3_cmd; mod sm3_cmd; +mod x25519_cmd; use crate::mac_cmd::HMACVariant; use crate::mldsa_cmd::MLDSAAction; @@ -1737,6 +1738,28 @@ enum Subcommands { /// Output the hashes in hex format. x: bool, }, + + /// The X25519 key agreement algorithm (RFC 7748), and the DHKEM(X25519, HKDF-SHA256) key + /// encapsulation built on it (RFC 9180). + X25519 { + action: x25519_cmd::X25519Action, + + #[arg(long)] + /// The private key file (in hex or binary) for agree and decaps + skfile: Option, + + #[arg(long)] + /// The public key file (in hex or binary) for agree and encaps + pkfile: Option, + + #[arg(long)] + /// The ciphertext value file (in hex or binary) either for encaps to output to, or for decaps to read from. + ctfile: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, } fn main() { @@ -2043,6 +2066,9 @@ fn run() { Some(Subcommands::TUPLEHASH256 { length, elements, customization, x }) => { sha3_cmd::tuplehash_cmd(256, *length, elements, customization, *x); } + Some(Subcommands::X25519 { action, skfile, pkfile, ctfile, x }) => { + x25519_cmd::x25519_cmd(action, skfile, pkfile, ctfile, *x); + } None => { eprintln!("No command provided. See -h") } diff --git a/cli/src/x25519_cmd.rs b/cli/src/x25519_cmd.rs new file mode 100644 index 000000000..347f60eb3 --- /dev/null +++ b/cli/src/x25519_cmd.rs @@ -0,0 +1,160 @@ +use crate::helpers::{ + read_from_file, read_from_file_or_stdin, write_bytes_or_hex, write_bytes_or_hex_to_file, +}; +use bouncycastle::core::key_material::KeyMaterialTrait; +use bouncycastle::core::traits::{ + KEMDecapsulator, KEMEncapsulator, KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey, +}; +use bouncycastle::ed::x25519::{POINT_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; +use clap::ValueEnum; +use std::process::exit; + +#[derive(ValueEnum, Clone, Debug)] +pub(crate) enum X25519Action { + /// Generate and output a new private key. + Keygen, + /// Output the public key of a private key read from skfile or stdin. + /// Accepts either binary or hex. + PkFromSk, + /// Accepts a sk (from skfile or stdin) and a pk (from pkfile), and checks that they match. + CheckConsistency, + /// Perform an X25519 key agreement between a private key (from skfile or stdin) and the + /// peer's public key (from pkfile), and output the shared secret to stdout. + /// The output is the raw X25519 shared secret: derive keys from it with a KDF, such as the + /// hkdf-sha256 subcommand, rather than using it as a key directly. + Agree, + /// Perform a DHKEM(X25519, HKDF-SHA256) encapsulation (RFC 9180 Sec 4.1) with a public key, + /// and output the shared secret and the ciphertext (the encapsulated key). + /// If a ctfile is provided, then the shared secret is output to stdout in either binary or hex + /// and the ciphertext to file also in binary or hex. + /// If no ctfile is provided then ct followed by ss are output to stdout in hex, separated by a newline. + /// The shared secret is always output to stdout to prevent it + /// staying in a file longer than it should and causing a vulnerability. + Encaps, + /// Perform a DHKEM(X25519, HKDF-SHA256) decapsulation with a private key and ciphertext, and + /// output the shared secret to stdout either in binary or hex. + /// The private key is read from skfile, and the ciphertext from ctfile or stdin, in binary or hex. + Decaps, +} + +pub(crate) fn x25519_cmd( + action: &X25519Action, + skfile: &Option, + pkfile: &Option, + ctfile: &Option, + output_hex: bool, +) { + match action { + X25519Action::Keygen => { + let (_pk, sk) = X25519::keygen().unwrap(); + write_bytes_or_hex(&sk.encode(), output_hex); + } + X25519Action::PkFromSk => { + let sk = parse_sk(&read_from_file_or_stdin(skfile)); + write_bytes_or_hex(&sk.pk().encode(), output_hex); + } + X25519Action::CheckConsistency => { + let sk = parse_sk(&read_from_file_or_stdin(skfile)); + let pk = parse_pk(&read_pkfile(pkfile)); + + match X25519::keypair_consistency_check(&pk, &sk) { + Ok(_) => { + crate::helpers::println_stdout("SUCCESS: pk and sk match."); + } + Err(_) => { + eprintln!("FAILURE: pk and sk do not match."); + exit(-1); + } + } + } + X25519Action::Agree => { + let sk = parse_sk(&read_from_file_or_stdin(skfile)); + let pk = parse_pk(&read_pkfile(pkfile)); + + let ss = match X25519::key_agreement(&pk, &sk) { + Ok(ss) => ss, + Err(e) => { + eprintln!("Error: {:?}", e); + exit(-1); + } + }; + write_bytes_or_hex(ss.as_ref(), output_hex); + } + X25519Action::Encaps => { + let pk = parse_pk(&read_from_file_or_stdin(pkfile)); + + let (ss, ct) = match X25519::encaps(&pk) { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {:?}", e); + exit(-1); + } + }; + + if ctfile.is_some() { + // write ct to file and ss to stdout + write_bytes_or_hex_to_file(&ct, ctfile.as_ref().unwrap(), output_hex); + write_bytes_or_hex(ss.ref_to_bytes(), output_hex); + } else { + // write both to stdout in hex, separated by a newline. + write_bytes_or_hex(&ct, true); + crate::helpers::write_stdout(b"\n"); + write_bytes_or_hex(ss.ref_to_bytes(), true); + } + } + X25519Action::Decaps => { + let sk = match skfile { + Some(f) => parse_sk(&read_from_file(f)), + None => { + eprintln!("Error: no skfile provided."); + exit(-1); + } + }; + + let ct = read_from_file_or_stdin(ctfile); + if ct.len() != POINT_SIZE { + eprintln!("Error: ciphertext is not the correct length."); + exit(-1); + } + + let ss = match X25519::decaps(&sk, &ct) { + Ok(ss) => ss, + Err(e) => { + eprintln!("Error: {:?}", e); + exit(-1); + } + }; + write_bytes_or_hex(ss.ref_to_bytes(), output_hex); + } + } +} + +fn read_pkfile(pkfile: &Option) -> Vec { + match pkfile { + Some(f) => read_from_file(f), + None => { + eprintln!("Error: no pkfile provided."); + exit(-1); + } + } +} + +fn parse_sk(bytes: &[u8]) -> X25519PrivateKey { + match X25519PrivateKey::from_bytes(bytes) { + Ok(sk) => sk, + Err(_) => { + eprintln!("Error: couldn't parse the input as a 32-byte X25519 private key."); + exit(-1); + } + } +} + +fn parse_pk(bytes: &[u8]) -> X25519PublicKey { + match X25519PublicKey::from_bytes(bytes) { + Ok(pk) => pk, + Err(_) => { + eprintln!("Error: couldn't parse the input as a 32-byte X25519 public key."); + exit(-1); + } + } +} diff --git a/cli/tests/test_x25519.sh b/cli/tests/test_x25519.sh new file mode 100755 index 000000000..fdc84ad00 --- /dev/null +++ b/cli/tests/test_x25519.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# The x25519 subcommand, end to end through the binary: X25519 key agreement (RFC 7748) and +# DHKEM(X25519, HKDF-SHA256) encapsulation (RFC 9180). +# +# The known answers: the key agreement is RFC 7748 Section 6.1; the decapsulation is RFC 9180 +# Appendix A.1.1. + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# RFC 7748 Section 6.1. +ALICE_SK=77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a +ALICE_PK=8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a +BOB_SK=5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb +BOB_PK=de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f +SHARED_K=4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742 + +# RFC 9180 Appendix A.1.1. +SK_RM=4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8 +PK_RM=3948cfe0ad1ddb695d780e59077195da6c56506b027329794ab02bca80815c4d +ENC=37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431 +SHARED_SECRET=fe0e18c9f024ce43799ae393c7e8fe8fce9d218875e8227b0187c04e7d2ea1fc + +# ---- known answers ------------------------------------------------------------------------ + +test_pk_from_sk_matches_rfc7748() { + local got + got=$(hex_out "$BC_RUST" x25519 pk-from-sk -x < <(printf '%s' "$ALICE_SK")) + assert_eq "$got" "$ALICE_PK" "RFC 7748 6.1 Alice's public key" + printf '%s' "$BOB_SK" >"$TMP/bob.sk" + got=$(hex_out "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/bob.sk" -x) + assert_eq "$got" "$BOB_PK" "RFC 7748 6.1 Bob's public key" +} + +test_agree_matches_rfc7748() { + local got + printf '%s' "$ALICE_SK" >"$TMP/alice.sk" + printf '%s' "$ALICE_PK" >"$TMP/alice.pk" + printf '%s' "$BOB_SK" >"$TMP/bob.sk" + printf '%s' "$BOB_PK" >"$TMP/bob.pk" + got=$(hex_out "$BC_RUST" x25519 agree --skfile "$TMP/alice.sk" --pkfile "$TMP/bob.pk" -x) + assert_eq "$got" "$SHARED_K" "RFC 7748 6.1 K, computed by Alice" + got=$(hex_out "$BC_RUST" x25519 agree --pkfile "$TMP/alice.pk" -x <"$TMP/bob.sk") + assert_eq "$got" "$SHARED_K" "RFC 7748 6.1 K, computed by Bob with the sk on stdin" +} + +test_decaps_matches_rfc9180_a1_1() { + local got + printf '%s' "$SK_RM" >"$TMP/sk" + got=$(hex_out "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/sk" -x) + assert_eq "$got" "$PK_RM" "RFC 9180 A.1.1 pkRm" + got=$(hex_out "$BC_RUST" x25519 decaps --skfile "$TMP/sk" -x < <(printf '%s' "$ENC")) + assert_eq "$got" "$SHARED_SECRET" "RFC 9180 A.1.1 shared_secret, enc on stdin" + printf '%s' "$ENC" >"$TMP/ct" + got=$(hex_out "$BC_RUST" x25519 decaps --skfile "$TMP/sk" --ctfile "$TMP/ct" -x) + assert_eq "$got" "$SHARED_SECRET" "RFC 9180 A.1.1 shared_secret, enc from --ctfile" +} + +# ---- binary and hex ----------------------------------------------------------------------- + +test_binary_keys_match_hex_keys() { + local from_hex from_bin + unhex "$ALICE_SK" >"$TMP/alice.sk" + unhex "$BOB_PK" >"$TMP/bob.pk" + from_bin=$(hex_out "$BC_RUST" x25519 agree --skfile "$TMP/alice.sk" --pkfile "$TMP/bob.pk" -x) + assert_eq "$from_bin" "$SHARED_K" "agree with binary key files" + "$BC_RUST" x25519 agree --skfile "$TMP/alice.sk" --pkfile "$TMP/bob.pk" >"$TMP/k" + assert_eq "$(hex "$TMP/k")" "$SHARED_K" "agree with binary output" + from_hex=$(hex_out "$BC_RUST" x25519 pk-from-sk -x <"$TMP/alice.sk") + assert_eq "$from_hex" "$ALICE_PK" "pk-from-sk with a binary sk on stdin" +} + +# ---- fresh keys --------------------------------------------------------------------------- + +test_keygen_outputs_fresh_keys() { + "$BC_RUST" x25519 keygen >"$TMP/sk1" + "$BC_RUST" x25519 keygen >"$TMP/sk2" + assert_size "$TMP/sk1" 32 "keygen binary private key" + assert_differs "$TMP/sk1" "$TMP/sk2" "two keygens must give different keys" + assert_eq "$(hex_out "$BC_RUST" x25519 keygen -x | wc -c | tr -d ' ')" 64 "keygen -x length" +} + +test_agree_with_fresh_keys() { + "$BC_RUST" x25519 keygen >"$TMP/a.sk" + "$BC_RUST" x25519 keygen >"$TMP/b.sk" + "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/a.sk" >"$TMP/a.pk" + "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/b.sk" >"$TMP/b.pk" + "$BC_RUST" x25519 agree --skfile "$TMP/a.sk" --pkfile "$TMP/b.pk" >"$TMP/k_a" + "$BC_RUST" x25519 agree --skfile "$TMP/b.sk" --pkfile "$TMP/a.pk" >"$TMP/k_b" + assert_size "$TMP/k_a" 32 "shared secret" + assert_same "$TMP/k_a" "$TMP/k_b" "both sides must agree" +} + +test_check_consistency() { + "$BC_RUST" x25519 keygen >"$TMP/sk" + "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/sk" >"$TMP/pk" + expect_ok "matching pair" \ + "$BC_RUST" x25519 check-consistency --skfile "$TMP/sk" --pkfile "$TMP/pk" >/dev/null + "$BC_RUST" x25519 keygen >"$TMP/other.sk" + expect_fail "mismatched pair" \ + "$BC_RUST" x25519 check-consistency --skfile "$TMP/other.sk" --pkfile "$TMP/pk" + assert_stderr_has "FAILURE" +} + +# ---- encaps / decaps ---------------------------------------------------------------------- + +test_encaps_decaps_round_trip_with_ctfile() { + "$BC_RUST" x25519 keygen >"$TMP/sk" + "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/sk" >"$TMP/pk" + "$BC_RUST" x25519 encaps --pkfile "$TMP/pk" --ctfile "$TMP/ct" >"$TMP/ss1" + "$BC_RUST" x25519 decaps --skfile "$TMP/sk" --ctfile "$TMP/ct" >"$TMP/ss2" + assert_size "$TMP/ct" 32 "ciphertext" + assert_size "$TMP/ss1" 32 "shared secret" + assert_same "$TMP/ss1" "$TMP/ss2" "decaps must recover the encapsulated secret" +} + +# Without --ctfile, encaps prints the ciphertext and then the shared secret, in hex, one per line. +test_encaps_to_stdout_round_trip() { + local ct ss got + "$BC_RUST" x25519 keygen >"$TMP/sk" + "$BC_RUST" x25519 pk-from-sk --skfile "$TMP/sk" | "$BC_RUST" x25519 encaps >"$TMP/out" + ct=$(sed -n 1p "$TMP/out") + ss=$(sed -n 2p "$TMP/out") + assert_eq "${#ct}" 64 "hex ciphertext length" + assert_eq "${#ss}" 64 "hex shared secret length" + got=$(hex_out "$BC_RUST" x25519 decaps --skfile "$TMP/sk" -x < <(printf '%s' "$ct")) + assert_eq "$got" "$ss" "decaps must recover the encapsulated secret" +} + +test_encaps_is_randomized() { + printf '%s' "$PK_RM" >"$TMP/pk" + "$BC_RUST" x25519 encaps --pkfile "$TMP/pk" --ctfile "$TMP/ct1" >"$TMP/ss1" + "$BC_RUST" x25519 encaps --pkfile "$TMP/pk" --ctfile "$TMP/ct2" >"$TMP/ss2" + assert_differs "$TMP/ct1" "$TMP/ct2" "two encapsulations must give different ciphertexts" + assert_differs "$TMP/ss1" "$TMP/ss2" "two encapsulations must give different secrets" +} + +# DHKEM rejects implicitly: the wrong private key gives a different secret, not an error. +test_decaps_with_wrong_key_gives_different_secret() { + local got + "$BC_RUST" x25519 keygen >"$TMP/sk" + got=$(hex_out "$BC_RUST" x25519 decaps --skfile "$TMP/sk" -x < <(printf '%s' "$ENC")) + assert_eq "${#got}" 64 "hex shared secret length" + [ "$got" != "$SHARED_SECRET" ] || fail "the wrong private key must not give the RFC secret" +} + +# ---- errors ------------------------------------------------------------------------------- + +# An all-zero public key has small order, so the shared secret would be all zeros, which RFC 9180 +# Section 7.1.4 and RFC 8446 Section 7.4.2 require rejecting. +test_low_order_public_key_is_rejected() { + printf '%s' "$ALICE_SK" >"$TMP/sk" + printf '%064d' 0 >"$TMP/zero.pk" + expect_fail "agree with an all-zero public key" \ + "$BC_RUST" x25519 agree --skfile "$TMP/sk" --pkfile "$TMP/zero.pk" + assert_stderr_has "InvalidPublicKey" + expect_fail "encaps to an all-zero public key" \ + "$BC_RUST" x25519 encaps --pkfile "$TMP/zero.pk" --ctfile "$TMP/ct" + expect_fail "decaps of an all-zero enc" \ + "$BC_RUST" x25519 decaps --skfile "$TMP/sk" --ctfile "$TMP/zero.pk" +} + +test_wrong_length_inputs_are_rejected() { + printf '%s' "$ALICE_SK" >"$TMP/sk" + printf '%s' "${BOB_PK:0:62}" >"$TMP/short.pk" + expect_fail "31-byte private key" "$BC_RUST" x25519 pk-from-sk < <(printf '%s' "${ALICE_SK:0:62}") + assert_stderr_has "private key" + expect_fail "31-byte public key" \ + "$BC_RUST" x25519 agree --skfile "$TMP/sk" --pkfile "$TMP/short.pk" + assert_stderr_has "public key" + expect_fail "31-byte ciphertext" \ + "$BC_RUST" x25519 decaps --skfile "$TMP/sk" --ctfile "$TMP/short.pk" + assert_stderr_has "ciphertext" +} + +test_missing_files_are_rejected() { + printf '%s' "$ALICE_SK" >"$TMP/sk" + expect_fail "agree without --pkfile" "$BC_RUST" x25519 agree <"$TMP/sk" + assert_stderr_has "pkfile" + expect_fail "check-consistency without --pkfile" "$BC_RUST" x25519 check-consistency <"$TMP/sk" + assert_stderr_has "pkfile" + expect_fail "decaps without --skfile" "$BC_RUST" x25519 decaps < <(printf '%s' "$ENC") + assert_stderr_has "skfile" +} + +run_all diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index da6c56a1b..d42ea1412 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -65,8 +65,9 @@ Within each, the priority order is: - [x] Decide what happens on an all-zero shared secret (low-order public key): `key_agreement` rejects it with `KeyAgreementError::InvalidPublicKey`. RFC 7748 §6.1 makes the check a MAY; RFC 8446 §7.4.2 and RFC 9180 §7.1.4 make it a MUST. - - [ ] Open: detect a small-order public key before the private-key operation, rather than - from the output. Is this even possible? Possible in constant-time? + - [x] Open: detect a small-order public key before the private-key operation, rather than + from the output. Is this even possible? Possible in constant-time? After analysis, while it's possible, there is + no reason to do this; it detects exactly the same bad public keys for no extra security. - [x] A `KEM` impl over X25519: `KEMEncapsulator`/`KEMDecapsulator` on `X25519` as DHKEM (X25519, HKDF-SHA256) (RFC 9180 §4.1), built on the generic `DHKEM` in the new `bouncycastle-hpke` utility crate. Tested against RFC 9180 A.1.1/A.1.2 and the core-test-framework KEM suites. @@ -75,7 +76,8 @@ Within each, the priority order is: - [x] `keygen_from_seed`, from a 32-byte `KeyMaterial256`; - [x] `keypair_consistency_check`. No `keygen_from_seed_and_encoded`: the seed *is* the private key encoding, so there is nothing to compare. -- [ ] Wire into the cli (we are going to skip Factory) +- [x] Wire into the cli (we are going to skip Factory): `bc-rust x25519` in `cli/src/x25519_cmd.rs`, + with keygen, pk-from-sk, check-consistency, agree, and DHKEM encaps/decaps. - [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). Scoped runs done on the KEM code (one survivor: an error-mapping arm unreachable with a well-behaved RNG) and on `bouncycastle-hpke`; a full-crate run is still to do. diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index b9352b349..48f11ac59 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -436,11 +436,6 @@ impl Eq for X25519PrivateKey {} pub struct X25519PublicKey([u8; POINT_SIZE]); impl X25519PublicKey { - /// Wraps 32 bytes as a u-coordinate. - pub const fn from_bytes(bytes: [u8; POINT_SIZE]) -> Self { - Self(bytes) - } - /// The 32 encoded bytes. pub const fn as_bytes(&self) -> &[u8; POINT_SIZE] { &self.0 diff --git a/crypto/ed/tests/x25519_dhkem_tests.rs b/crypto/ed/tests/x25519_dhkem_tests.rs index 204a0fb0c..9a8d28c70 100644 --- a/crypto/ed/tests/x25519_dhkem_tests.rs +++ b/crypto/ed/tests/x25519_dhkem_tests.rs @@ -80,7 +80,7 @@ fn keygen() -> Result<(X25519PublicKey, X25519PrivateKey), KEMError> { #[test] fn rfc9180_encaps() { for (i, v) in VECTORS.iter().enumerate() { - let pk_r = X25519PublicKey::from_bytes(decode_32(v.pk_rm)); + let pk_r = X25519PublicKey::from_bytes(&decode_32(v.pk_rm)).unwrap(); let mut rng = FixedSeedRNG::new(decode_32(v.sk_em)); let (ss, enc) = X25519::encaps_rng(&pk_r, &mut rng).unwrap(); assert_eq!(enc, decode_32(v.enc), "vector {i}: enc"); @@ -235,7 +235,7 @@ fn rejects_low_order_public_key() { let p = decode_32("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"); for u in [[0_u8; 32], p, zero_bit_255] { - match X25519::encaps(&X25519PublicKey::from_bytes(u)) { + match X25519::encaps(&X25519PublicKey::from_bytes(&u).unwrap()) { Err(KEMError::InvalidPublicKey(_)) => { /* good */ } other => panic!("encaps: expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), } diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs index 304ec1f6e..df7611c21 100644 --- a/crypto/ed/tests/x25519_key_tests.rs +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -166,7 +166,7 @@ fn clamp(mut k: [u8; 32]) -> [u8; 32] { #[test] fn public_key_encoding() { let bytes = decode_32(ALICE_PK); - let pk = X25519PublicKey::from_bytes(bytes); + let pk = X25519PublicKey::from_bytes(&bytes).unwrap(); assert_eq!(KeyAgreementPublicKey::encode(&pk), bytes); let mut out = [0xFF_u8; 32]; diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index bfe196ae5..25a5d8099 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -1,6 +1,6 @@ use bouncycastle_core::errors::KeyAgreementError; use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; -use bouncycastle_core::traits::KeyAgreement; +use bouncycastle_core::traits::{KeyAgreement, KeyAgreementPublicKey}; use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; @@ -97,7 +97,7 @@ fn key_agreement_rejects_all_zero_shared_secret() { let sk = sk_from_bytes(&decode_32( "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", )); - match X25519::key_agreement(&X25519PublicKey::from_bytes(u), &sk) { + match X25519::key_agreement(&X25519PublicKey::from_bytes(&u).unwrap(), &sk) { Err(KeyAgreementError::InvalidPublicKey(_)) => { /* good */ } other => panic!("expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), } @@ -129,7 +129,9 @@ fn rfc7748_check_iterated(count: usize) { u[0] = 9; let mut iterations = 0; while iterations < count { - let r = X25519::key_agreement(&X25519PublicKey::from_bytes(u), &sk_from_bytes(&k)).unwrap(); + let r = + X25519::key_agreement(&X25519PublicKey::from_bytes(&u).unwrap(), &sk_from_bytes(&k)) + .unwrap(); u = k; k = *r.as_bytes(); @@ -164,7 +166,7 @@ fn check_value(n: &[u8], text: &str, se: &str) { fn check_vector(sk: &str, su: &str, se: &str, text: &str) { let k = sk_from_bytes(&decode_32(sk)); - let u = X25519PublicKey::from_bytes(decode_32(su)); + let u = X25519PublicKey::from_bytes(&decode_32(su)).unwrap(); let r = X25519::key_agreement(&u, &k).unwrap(); check_value(r.as_bytes(), text, se); diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index 9e9dede98..740ab4816 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -13,7 +13,7 @@ use bouncycastle_core::errors::KeyAgreementError; use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; -use bouncycastle_core::traits::KeyAgreement; +use bouncycastle_core::traits::{KeyAgreement, KeyAgreementPublicKey}; use bouncycastle_core_test_framework::test_data_loaders::{Value, hex_field, wycheproof_json}; use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; @@ -43,7 +43,7 @@ fn wycheproof_x25519() { hex_field(test, "public", tc_id).try_into().expect("a 32-byte public key"); let shared = hex_field(test, "shared", tc_id); - let (k, u) = (sk_from_bytes(&k), X25519PublicKey::from_bytes(u)); + let (k, u) = (sk_from_bytes(&k), X25519PublicKey::from_bytes(&u).unwrap()); match X25519::key_agreement(&u, &k) { Ok(r) => assert_eq!(r.as_bytes(), shared.as_slice(), "{ctx}: shared secret"), Err(KeyAgreementError::InvalidPublicKey(_)) => { From ab7997cc73af2546a50cddaca8ecdaad232ab24a Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 14:57:50 -0500 Subject: [PATCH 19/26] Re-ran mutants. Nothing new found. --- crypto/ed/ed_todo.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index d42ea1412..d4ebf13f6 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -78,9 +78,11 @@ Within each, the priority order is: private key encoding, so there is nothing to compare. - [x] Wire into the cli (we are going to skip Factory): `bc-rust x25519` in `cli/src/x25519_cmd.rs`, with keygen, pk-from-sk, check-consistency, agree, and DHKEM encaps/decaps. -- [ ] `cargo mutants -p bouncycastle-ed` (without Wycheproof). Scoped runs done on the KEM code (one survivor: an - error-mapping arm unreachable with a well-behaved RNG) and on `bouncycastle-hpke`; - a full-crate run is still to do. +- [x] `cargo mutants -p bouncycastle-ed` (without Wycheproof): 432 mutants, 327 caught, 95 unviable, + 2 timeouts (`sqr_n`'s loop counter, which never terminates: kills), 8 missed. Seven are `|` -> `^` + in `decode64_255`/`encode64_255`, where the OR'd operands have disjoint bits (equivalent). One is + the `KeyGenError` arm in the DHKEM `generate_key_pair_rng`, reachable only with an RNG whose + `fill_keymaterial_out` returns a wrongly-typed seed. ## Phase 3: Docs and ergonomics From 85045e8284d67d10bb678521bd7c5fad00976e86 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 17:42:43 -0500 Subject: [PATCH 20/26] Added perf and memory benches. --- crypto/ed/Cargo.toml | 5 + crypto/ed/benches/x25519_benches.rs | 125 +++++++++++++ crypto/ed/ed_todo.md | 21 ++- crypto/ed/src/lib.rs | 4 +- crypto/ed/src/x25519.rs | 94 +++++++--- crypto/hpke/src/dhkem.rs | 3 + mem_usage_benches/Cargo.toml | 4 + .../src/bench_x25519_mem_usage.rs | 169 ++++++++++++++++++ mem_usage_benches/src/lib.rs | 1 + 9 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 crypto/ed/benches/x25519_benches.rs create mode 100644 mem_usage_benches/src/bench_x25519_mem_usage.rs diff --git a/crypto/ed/Cargo.toml b/crypto/ed/Cargo.toml index ff3805be0..99ad2aff4 100644 --- a/crypto/ed/Cargo.toml +++ b/crypto/ed/Cargo.toml @@ -14,3 +14,8 @@ bouncycastle-utils.workspace = true bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true bouncycastle-rng.workspace = true +criterion.workspace = true + +[[bench]] +name = "x25519_benches" +harness = false diff --git a/crypto/ed/benches/x25519_benches.rs b/crypto/ed/benches/x25519_benches.rs new file mode 100644 index 000000000..fe710268c --- /dev/null +++ b/crypto/ed/benches/x25519_benches.rs @@ -0,0 +1,125 @@ +use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; +use bouncycastle_core::traits::{KEMDecapsulator, KEMEncapsulator, KeyAgreement}; +use bouncycastle_core_test_framework::{DUMMY_SEED, FixedSeedRNG}; +use bouncycastle_ed::x25519::{X25519, X25519PrivateKey, X25519PublicKey}; +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; + +/// The number of distinct keys each benchmark iterates over, so that the CPU does not cache one +/// input or learn its branches. +const NUM_ELEMS: usize = 32; + +/// `NUM_ELEMS` distinct 32-byte seeds, taken from `DUMMY_SEED`. +fn seeds() -> Vec { + DUMMY_SEED + .chunks(32) + .take(NUM_ELEMS) + .enumerate() + .map(|(i, chunk)| { + // The chunks of DUMMY_SEED repeat every 8; mixing in the index keeps all NUM_ELEMS + // seeds distinct. + let mut bytes: [u8; 32] = chunk.try_into().unwrap(); + bytes[0] ^= i as u8; + KeyMaterial256::from_bytes_as_type(&bytes, KeyType::Seed).unwrap() + }) + .collect() +} + +/// A key pair for each of [`seeds`]. +fn key_pairs() -> Vec<(X25519PublicKey, X25519PrivateKey)> { + seeds().iter().map(|seed| X25519::keygen_from_seed(seed).unwrap()).collect() +} + +fn bench_x25519_keygen(c: &mut Criterion) { + let mut group = c.benchmark_group("KeyGen"); + + // Set up the seeds outside of the timing loop. + let seeds = seeds(); + + group.throughput(criterion::Throughput::Elements(seeds.len() as u64)); + + group.bench_function("X25519", |b| { + b.iter(|| { + for seed in seeds.iter() { + _ = black_box(X25519::keygen_from_seed(seed)); + } + }) + }); + + group.finish(); +} + +fn bench_x25519_key_agreement(c: &mut Criterion) { + let mut group = c.benchmark_group("KeyAgreement"); + + let keys = key_pairs(); + + group.throughput(criterion::Throughput::Elements(keys.len() as u64)); + + // Each private key agrees with the next key pair's public key. + group.bench_function("X25519", |b| { + b.iter(|| { + for i in 0..keys.len() { + let pk = &keys[(i + 1) % keys.len()].0; + _ = black_box(X25519::key_agreement(pk, &keys[i].1)); + } + }) + }); + + group.finish(); +} + +fn bench_x25519_encaps(c: &mut Criterion) { + let mut group = c.benchmark_group("Encaps"); + + let keys = key_pairs(); + + // A fixed-output RNG per encapsulation, so that we're not measuring the time of the DRBG. + let mut ephemeral_seeds = [[0u8; 32]; NUM_ELEMS]; + for (i, seed) in ephemeral_seeds.iter_mut().enumerate() { + seed.fill(i as u8 + 1); + } + + group.throughput(criterion::Throughput::Elements(NUM_ELEMS as u64)); + + group.bench_function("DHKEM(X25519, HKDF-SHA256)", |b| { + b.iter(|| { + for i in 0..NUM_ELEMS { + let mut rng = FixedSeedRNG::new(ephemeral_seeds[i]); + _ = black_box(X25519::encaps_rng(&keys[i].0, &mut rng)); + } + }) + }); + + group.finish(); +} + +fn bench_x25519_decaps(c: &mut Criterion) { + let mut group = c.benchmark_group("Decaps"); + + let keys = key_pairs(); + + // Set up the ciphertexts outside of the timing loop. + let cts: Vec<[u8; 32]> = keys.iter().map(|(pk, _sk)| X25519::encaps(pk).unwrap().1).collect(); + + group.throughput(criterion::Throughput::Elements(NUM_ELEMS as u64)); + + group.bench_function("DHKEM(X25519, HKDF-SHA256)", |b| { + b.iter(|| { + for i in 0..NUM_ELEMS { + _ = black_box(X25519::decaps(&keys[i].1, &cts[i])); + } + }) + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_x25519_keygen, + bench_x25519_key_agreement, + bench_x25519_encaps, + bench_x25519_decaps, +); +criterion_main!(benches); diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index d4ebf13f6..b6e016ae4 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -82,7 +82,8 @@ Within each, the priority order is: 2 timeouts (`sqr_n`'s loop counter, which never terminates: kills), 8 missed. Seven are `|` -> `^` in `decode64_255`/`encode64_255`, where the OR'd operands have disjoint bits (equivalent). One is the `KeyGenError` arm in the DHKEM `generate_key_pair_rng`, reachable only with an RNG whose - `fill_keymaterial_out` returns a wrongly-typed seed. + `fill_keymaterial_out` returns a wrongly-typed seed. Run before `X25519PrivateKey` stored its + public key; re-run. ## Phase 3: Docs and ergonomics @@ -96,15 +97,27 @@ Within each, the priority order is: - [x] Rename `parity` (to `is_odd`?). - [x] Remove the answered "why are we cloning?" TODO. - [ ] Docs: - - [ ] Re-enable `#![forbid(missing_docs)]`. + - [x] Re-enable `#![forbid(missing_docs)]`. - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md: - [x] Usage Examples (`lib.rs`, linking to the `x25519` module's examples); - - [ ] Memory Usage (needs the benches below); + - [x] Memory Usage (`x25519.rs` module docs); - [ ] Security Considerations. - [x] Is XDH in a NIST document? No: RFC 7748 only. SP 800-186 specifies the curves but does not list them for key establishment, and SP 800-56A Rev. 3 does not include XDH (`lib.rs` `# Standards`). -- [ ] Benches: criterion, plus a `mem_usage_benches` harness and a `# Memory Usage` docs section. +- [x] Benches: criterion, plus a `mem_usage_benches` harness and a `# Memory Usage` docs section. + - [x] criterion: `benches/x25519_benches.rs` (KeyGen, KeyAgreement, Encaps, Decaps). + - [x] `mem_usage_benches/src/bench_x25519_mem_usage.rs`. Every row reads 7696 B, the harness + floor (the dynamic loader, before `main`); the operations peak at 2.2 to 3.0 kB. + - [x] memory-hygiene pass: `extract_and_expand` made `#[inline(never)]` (Encaps -656 B). + - [x] `# Memory Usage` docs section (`x25519.rs` module docs): key sizes, and the deepest frame + chain per entry point, as AES and Ascon do, since every operation is below the massif + harness floor. +- [x] `X25519PrivateKey` stores its public key (64 B in memory), computed once on keygen or decode: + `pk()` is free and Decaps no longer recomputes `pk(skR)` (about 48% faster). +- [ ] Decide on splitting the ladder out of `scalar_mult` into an `#[inline(never)]` helper + (-416 B KeyGen, -424 B KeyAgreement, no measurable speed cost). Measured before the stored + public key; re-measure, and regenerate the `# Memory Usage` table if taken. - [ ] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) --- diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index 4c8742518..eaf5f8c94 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -21,9 +21,7 @@ #![no_std] #![forbid(unsafe_code)] - -// TODO -- #![forbid(missing_docs)] -// Add back at the end +#![forbid(missing_docs)] mod curve25519; pub mod x25519; diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 48f11ac59..927b18978 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -170,6 +170,33 @@ //! Like any unauthenticated Diffie-Hellman, this resists only passive eavesdroppers. An active //! attacker who can substitute public keys or pairwise values in transit can sit in the middle, so a //! real protocol must authenticate them, for example by signing them. +//! +//! # Memory Usage +//! +//! There are no lookup tables and no heap allocation, and no state is held between calls other +//! than the keys: +//! +//! | Key Object | PK size on disk | PK size in memory | SK size on disk | SK size in memory | +//! |------------|-----------------|-------------------|-----------------|-------------------| +//! | X25519 | 32 | 32 | 32 | 64 | +//! +//! All values are in bytes. The "in memory" sizes are measured by rust's `std::mem::size_of`. +//! [`X25519PrivateKey`] holds its public key alongside the 32-byte scalar. +//! +//! Per-call stack usage, measured as the deepest frame chain below each entry point in the release +//! build (x86-64 Linux, rustc 1.99.0, return addresses included): +//! +//! | Entry point | Stack | +//! |---------------------------------|--------| +//! | `X25519::keygen_from_seed` | 1616 B | +//! | `X25519PrivateKey::from_bytes` | 1552 B | +//! | `X25519::key_agreement` | 1488 B | +//! | `X25519::encaps` / `encaps_rng` | 2368 B | +//! | `X25519::decaps` | 1872 B | +//! +//! Key generation, decoding a private key, and key agreement each run one Montgomery ladder plus +//! the field inversion. The KEM figures are set by the HKDF-SHA256 key schedule; for `encaps`, the +//! default DRBG's chain is shallower, and for `encaps_rng`, the caller's RNG is not counted. use bouncycastle_core::errors::{KEMError, KeyAgreementError, RNGError}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterial256, KeyMaterialTrait, KeyType}; @@ -194,7 +221,11 @@ use crate::curve25519::FieldElement25519; /*** Constants ***/ +/// Size in bytes of the encoded representation of a point on curve25519, +/// which are used as public keys. pub const POINT_SIZE: usize = 32; +/// Size in bytes of the encoded representation of a scalar in the curve25519 field, +/// which are used as private keys. pub const SCALAR_SIZE: usize = 32; /// String name of the X25519 algorithm. @@ -276,7 +307,7 @@ impl X25519 { let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); // Cannot panic: the length was checked above. k.copy_from_slice(seed.ref_to_bytes()); - let sk = X25519PrivateKey(k); + let sk = X25519PrivateKey { pk: scalar_mult_base(&k), k }; Ok((sk.pk(), sk)) } @@ -333,7 +364,7 @@ impl KeyAgreement fo /// `X25519(sk, pk)`, rejecting an all-zero output; see [`X25519::key_agreement`]. fn dh(sk: &X25519PrivateKey, pk: &X25519PublicKey) -> Result { - let r = scalar_mult(sk, pk); + let r = scalar_mult(&sk.k, pk); if r.is_zero() { return Err(KeyAgreementError::InvalidPublicKey( "Public key has small order: the shared secret is all zeros", @@ -348,46 +379,54 @@ fn dh(sk: &X25519PrivateKey, pk: &X25519PublicKey) -> Result); +pub struct X25519PrivateKey { + k: Secret<[u8; SCALAR_SIZE]>, + pk: X25519PublicKey, +} impl X25519PrivateKey { - /// Computes the public key, `X25519(k, 9)` (RFC 7748 §6.1). - /// - /// The public key is not stored with the private key, so each call runs a full scalar - /// multiplication. + /// The public key, `X25519(k, 9)` (RFC 7748 §6.1). pub fn pk(&self) -> X25519PublicKey { - scalar_mult_base(self) + self.pk } - /// Checks that `pk` is this private key's public key, by computing it with [`Self::pk`] and + /// Checks that `pk` is this private key's public key, by computing it from the scalar and /// comparing. /// /// Returns either `()` or [`KeyAgreementError::ConsistencyCheckFailed`]. pub fn consistency_check(&self, pk: &X25519PublicKey) -> Result<(), KeyAgreementError> { - if self.pk() == *pk { + if scalar_mult_base(&self.k) == *pk { Ok(()) } else { Err(KeyAgreementError::ConsistencyCheckFailed("Public key does not match private key")) } } - /// The key bytes with `decodeScalar25519`'s fixed bits applied (RFC 7748 §5): - /// - /// ```text - /// k_list[0] &= 248 - /// k_list[31] &= 127 - /// k_list[31] |= 64 - /// ``` + /// The key bytes, [`clamp`]ed. fn clamped(&self) -> Secret<[u8; SCALAR_SIZE]> { - let mut k = self.0.clone(); - k[0] &= 248; - k[31] &= 127; - k[31] |= 64; + let mut k = self.k.clone(); + clamp(&mut k); k } } +/// Applies `decodeScalar25519`'s fixed bits to the key bytes `k` (RFC 7748 §5): +/// +/// ```text +/// k_list[0] &= 248 +/// k_list[31] &= 127 +/// k_list[31] |= 64 +/// ``` +fn clamp(k: &mut [u8; SCALAR_SIZE]) { + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; +} + /// The encoding is the 32 key bytes, clamped (the bit operations of `decodeScalar25519`, RFC 7748 /// §5), so keys that compare equal have the same encoding. This is also the DHKEM serialization: /// RFC 9180 §7.1.2 says "The SerializePrivateKey() function MUST clamp its output and the @@ -414,7 +453,8 @@ impl KeyAgreementPrivateKey for X25519PrivateKey { })?; let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); *k = *bytes; - Ok(Self(X25519PrivateKey(k).clamped())) + clamp(&mut k); + Ok(Self { pk: scalar_mult_base(&k), k }) } } @@ -525,8 +565,8 @@ impl Display for X25519PublicKey { /// /// Input: A 32-byte little-endian scalar. /// Output: The decoded scalar, written to `n` as eight little-endian 32-bit words. -fn decode_scalar_25519(k: &X25519PrivateKey, n: &mut [u32; 8]) { - let (words, _) = k.0.as_chunks::<4>(); // 32 bytes: exactly 8 chunks, no remainder +fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { + let (words, _) = k.as_chunks::<4>(); // 32 bytes: exactly 8 chunks, no remainder for (n_i, w) in n.iter_mut().zip(words) { *n_i = u32::from_le_bytes(*w); } @@ -617,7 +657,7 @@ fn ladder_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { /// * `k`, a 32-byte scalar /// * `u`, a 32-byte little-endian u-coordinate /// Output: The canonical little-endian encoding of the u-coordinate of `k * u`. -fn scalar_mult(k: &X25519PrivateKey, u: &X25519PublicKey) -> X25519PublicKey { +fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { // The decoded scalar is as secret as `k` itself. let mut n: Secret<[u32; 8]> = Secret::new(); decode_scalar_25519(k, &mut n); @@ -727,7 +767,7 @@ fn scalar_mult(k: &X25519PrivateKey, u: &X25519PublicKey) -> X25519PublicKey { X25519PublicKey(out) } -fn scalar_mult_base(k: &X25519PrivateKey) -> X25519PublicKey { +fn scalar_mult_base(k: &[u8; SCALAR_SIZE]) -> X25519PublicKey { // Equivalent (but much slower) -- since the BASE_POINT never changes, you can // precompute lookup tables for the multiples of BASE_POINT. TODO scalar_mult(k, &BASE_POINT) @@ -766,7 +806,7 @@ impl DHKEMParams for X25519 { fn private_key_from_bytes(sk: &[u8; SCALAR_SIZE]) -> X25519PrivateKey { let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); *k = *sk; - X25519PrivateKey(k) + X25519PrivateKey { pk: scalar_mult_base(&k), k } } fn pk(sk: &X25519PrivateKey) -> X25519PublicKey { diff --git a/crypto/hpke/src/dhkem.rs b/crypto/hpke/src/dhkem.rs index 996c2735d..566339dfd 100644 --- a/crypto/hpke/src/dhkem.rs +++ b/crypto/hpke/src/dhkem.rs @@ -218,6 +218,9 @@ impl< /// /// The output is labelled as a full-entropy key at the group's strength: it is HKDF output, and /// so pseudorandom, unlike the DH output it is derived from. + // #[inline(never)]: inlined into encap_rng, its two HMAC states were allocated for the whole + // encapsulation, on top of the key generation that sets the peak. + #[inline(never)] fn extract_and_expand(dh: &[u8], kem_context: &[&[u8]]) -> KeyMaterial { // eae_prk = LabeledExtract("", "eae_prk", dh) let eae_prk = Self::labeled_extract(LABEL_EAE_PRK, dh); diff --git a/mem_usage_benches/Cargo.toml b/mem_usage_benches/Cargo.toml index fb55fb094..fa6e5548a 100644 --- a/mem_usage_benches/Cargo.toml +++ b/mem_usage_benches/Cargo.toml @@ -30,3 +30,7 @@ path = "src/bench_ccm_mem_usage.rs" [[bin]] name = "bench_ascon_mem_usage" path = "src/bench_ascon_mem_usage.rs" + +[[bin]] +name = "bench_x25519_mem_usage" +path = "src/bench_x25519_mem_usage.rs" diff --git a/mem_usage_benches/src/bench_x25519_mem_usage.rs b/mem_usage_benches/src/bench_x25519_mem_usage.rs new file mode 100644 index 000000000..737c2d419 --- /dev/null +++ b/mem_usage_benches/src/bench_x25519_mem_usage.rs @@ -0,0 +1,169 @@ +//! 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_x25519_mem_usage > /dev/null +//! +//! ms_print massif.out.* +//! ``` +//! +//! Make sure you build in release mode! +//! +//! Note: print!() forces the compiler not to optimize away the actual code. The name of the bench +//! goes to stderr so that the junk on stdout can be piped to /dev/null. +//! +//! Main is at the bottom, controls which bench is actually run. +//! +//! The keys and the ciphertext are the published test vectors (RFC 7748 Section 6.1 and RFC 9180 +//! Appendix A.1.1), hard-coded so that no keygen runs in a bench that does not measure it. + +#![allow(dead_code)] +#![allow(unused_imports)] + +use bouncycastle::core::key_material::{KeyMaterial256, KeyType}; +use bouncycastle::core::traits::{ + KEMDecapsulator, KEMEncapsulator, KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey, +}; +use bouncycastle::ed::x25519::{X25519, X25519PrivateKey, X25519PublicKey}; + +/// Runs the operation under test in its own stack frame, with the key already loaded by the +/// caller. +/// +/// `#[inline(never)]` matters here too: the bench functions inline the whole operation, so +/// without this boundary the operation's frame would be allocated on entry to the bench and the +/// key-load helper's frame would stack on top of it, reporting their sum instead of their max. +/// The closure returns nothing, so no result is copied back across the boundary. +#[inline(never)] +fn measure(f: impl FnOnce()) { + f() +} + +/// This exists so I can use /usr/bin/time to measure the base memory footprint of the harness. +fn bench_do_nothing() { + eprintln!("DoNothing"); + + print!("{}", 1 + 1); +} + +/// This prints the in-memory size of the public and private key structs. +fn print_struct_sizes() { + println!("X25519PublicKey: {}", size_of::()); + println!("X25519PrivateKey: {}", size_of::()); +} + +/// Loads RFC 7748 Section 6.1's Alice private key. `#[inline(never)]` so its byte array and decode +/// temporaries are popped before the operation runs. +#[inline(never)] +fn load_alice_sk() -> X25519PrivateKey { + X25519PrivateKey::from_bytes(&[ + 0x77, 0x07, 0x6d, 0x0a, 0x73, 0x18, 0xa5, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2, 0x66, + 0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5, 0x1d, 0xb9, + 0x2c, 0x2a, + ]) + .unwrap() +} + +/// Loads RFC 7748 Section 6.1's Bob public key. +#[inline(never)] +fn load_bob_pk() -> X25519PublicKey { + X25519PublicKey::from_bytes(&[ + 0xde, 0x9e, 0xdb, 0x7d, 0x7b, 0x7d, 0xc1, 0xb4, 0xd3, 0x5b, 0x61, 0xc2, 0xec, 0xe4, 0x35, + 0x37, 0x3f, 0x83, 0x43, 0xc8, 0x5b, 0x78, 0x67, 0x4d, 0xad, 0xfc, 0x7e, 0x14, 0x6f, 0x88, + 0x2b, 0x4f, + ]) + .unwrap() +} + +/// Loads RFC 9180 Appendix A.1.1's skRm. +#[inline(never)] +fn load_sk_rm() -> X25519PrivateKey { + X25519PrivateKey::from_bytes(&[ + 0x46, 0x12, 0xc5, 0x50, 0x26, 0x3f, 0xc8, 0xad, 0x58, 0x37, 0x5d, 0xf3, 0xf5, 0x57, 0xaa, + 0xc5, 0x31, 0xd2, 0x68, 0x50, 0x90, 0x3e, 0x55, 0xa9, 0xf2, 0x3f, 0x21, 0xd8, 0x53, 0x4e, + 0x8a, 0xc8, + ]) + .unwrap() +} + +/// Loads RFC 9180 Appendix A.1.1's pkRm. +#[inline(never)] +fn load_pk_rm() -> X25519PublicKey { + X25519PublicKey::from_bytes(&[ + 0x39, 0x48, 0xcf, 0xe0, 0xad, 0x1d, 0xdb, 0x69, 0x5d, 0x78, 0x0e, 0x59, 0x07, 0x71, 0x95, + 0xda, 0x6c, 0x56, 0x50, 0x6b, 0x02, 0x73, 0x29, 0x79, 0x4a, 0xb0, 0x2b, 0xca, 0x80, 0x81, + 0x5c, 0x4d, + ]) + .unwrap() +} + +/// Key generation from a seed (RFC 7748 Section 6.1's Alice private key): one fixed-base scalar +/// multiplication. The seed is the input, so it is built in the bench body. +fn bench_x25519_keygen() { + eprintln!("X25519/KeyGen"); + + let seed = KeyMaterial256::from_bytes_as_type( + &[ + 0x77, 0x07, 0x6d, 0x0a, 0x73, 0x18, 0xa5, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2, + 0x66, 0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5, + 0x1d, 0xb9, 0x2c, 0x2a, + ], + KeyType::Seed, + ) + .unwrap(); + + measure(|| { + let (pk, _sk) = X25519::keygen_from_seed(&seed).unwrap(); + print!("{:x?}", pk.encode()); + }); +} + +/// Key agreement: one variable-base scalar multiplication and the all-zero check. +fn bench_x25519_key_agreement() { + eprintln!("X25519/KeyAgreement"); + + let sk = load_alice_sk(); + let pk = load_bob_pk(); + measure(|| { + let k = X25519::key_agreement(&pk, &sk).unwrap(); + print!("{:x?}", k.encode()); + }); +} + +/// DHKEM(X25519, HKDF-SHA256) encapsulation, including the OS-seeded DRBG that generates the +/// ephemeral key, since that is part of what an encapsulation costs. +fn bench_x25519_encaps() { + eprintln!("X25519/Encaps"); + + let pk = load_pk_rm(); + measure(|| { + let (ss, ct) = X25519::encaps(&pk).unwrap(); + print!("{:x?}", ss); + print!("{:x?}", ct); + }); +} + +/// DHKEM(X25519, HKDF-SHA256) decapsulation of RFC 9180 Appendix A.1.1's enc. +fn bench_x25519_decaps() { + eprintln!("X25519/Decaps"); + + let ct: [u8; 32] = [ + 0x37, 0xfd, 0xa3, 0x56, 0x7b, 0xdb, 0xd6, 0x28, 0xe8, 0x86, 0x68, 0xc3, 0xc8, 0xd7, 0xe9, + 0x7d, 0x1d, 0x12, 0x53, 0xb6, 0xd4, 0xea, 0x6d, 0x44, 0xc1, 0x50, 0xf7, 0x41, 0xf1, 0xbf, + 0x44, 0x31, + ]; + + let sk = load_sk_rm(); + measure(|| { + let ss = X25519::decaps(&sk, &ct).unwrap(); + print!("{:x?}", ss); + }); +} + +fn main() { + // print_struct_sizes() + // bench_do_nothing() + bench_x25519_keygen() + // bench_x25519_key_agreement() + // bench_x25519_encaps() + // bench_x25519_decaps() +} diff --git a/mem_usage_benches/src/lib.rs b/mem_usage_benches/src/lib.rs index 8c3f55926..677db1c96 100644 --- a/mem_usage_benches/src/lib.rs +++ b/mem_usage_benches/src/lib.rs @@ -4,3 +4,4 @@ mod bench_ccm_mem_usage; mod bench_mldsa_mem_usage; mod bench_mlkem_mem_usage; mod bench_sha3_mem_usage; +mod bench_x25519_mem_usage; From a338dbf40a7da8f2cabf66aa0419b3e05ca6af5c Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 19:03:02 -0500 Subject: [PATCH 21/26] Split the ladder into two functions, which lowers x25519 stack usage from ~1600 to ~1200 bytes. --- crypto/ed/ed_todo.md | 12 ++++----- crypto/ed/src/x25519.rs | 58 ++++++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index b6e016ae4..1154ef425 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -82,8 +82,8 @@ Within each, the priority order is: 2 timeouts (`sqr_n`'s loop counter, which never terminates: kills), 8 missed. Seven are `|` -> `^` in `decode64_255`/`encode64_255`, where the OR'd operands have disjoint bits (equivalent). One is the `KeyGenError` arm in the DHKEM `generate_key_pair_rng`, reachable only with an RNG whose - `fill_keymaterial_out` returns a wrongly-typed seed. Run before `X25519PrivateKey` stored its - public key; re-run. + `fill_keymaterial_out` returns a wrongly-typed seed. Re-run on `x25519.rs` after the stored + public key and the ladder split: 158 mutants, 78 caught, 79 unviable, only that arm missed. ## Phase 3: Docs and ergonomics @@ -115,10 +115,10 @@ Within each, the priority order is: harness floor. - [x] `X25519PrivateKey` stores its public key (64 B in memory), computed once on keygen or decode: `pk()` is free and Decaps no longer recomputes `pk(skR)` (about 48% faster). -- [ ] Decide on splitting the ladder out of `scalar_mult` into an `#[inline(never)]` helper - (-416 B KeyGen, -424 B KeyAgreement, no measurable speed cost). Measured before the stored - public key; re-measure, and regenerate the `# Memory Usage` table if taken. -- [ ] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) +- [x] Split the ladder out of `scalar_mult` into an `#[inline(never)]` `ladder`, so the field + inversion no longer runs on top of the ladder's registers: -416 B on KeyGen, KeyAgreement and + private-key decoding, no speed cost. Encaps/Decaps unchanged (HKDF sets their peak). +- [x] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) --- diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 927b18978..782637328 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -188,15 +188,15 @@ //! //! | Entry point | Stack | //! |---------------------------------|--------| -//! | `X25519::keygen_from_seed` | 1616 B | -//! | `X25519PrivateKey::from_bytes` | 1552 B | -//! | `X25519::key_agreement` | 1488 B | +//! | `X25519::keygen_from_seed` | 1200 B | +//! | `X25519PrivateKey::from_bytes` | 1136 B | +//! | `X25519::key_agreement` | 1072 B | //! | `X25519::encaps` / `encaps_rng` | 2368 B | //! | `X25519::decaps` | 1872 B | //! //! Key generation, decoding a private key, and key agreement each run one Montgomery ladder plus -//! the field inversion. The KEM figures are set by the HKDF-SHA256 key schedule; for `encaps`, the -//! default DRBG's chain is shallower, and for `encaps_rng`, the caller's RNG is not counted. +//! the field inversion. +//! The KEM figures additionally run HPKE.DHKEM and stack usage is dominated by the HKDF-SHA256 key schedule. use bouncycastle_core::errors::{KEMError, KeyAgreementError, RNGError}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterial256, KeyMaterialTrait, KeyType}; @@ -669,8 +669,29 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { debug_assert_eq!(0, n[0] & 7); let x1 = decode_u_coordinate(u); - let mut x2 = x1.clone(); - let mut z2 = FieldElement25519::one(); + let mut x2 = FieldElement25519::zero(); + let mut z2 = FieldElement25519::zero(); + ladder(&n, &x1, &mut x2, &mut z2); + + x2 = x2.mul(&z2.inv()); + let mut out = [0_u8; POINT_SIZE]; + encode_u_coordinate(&x2, &mut out); + X25519PublicKey(out) +} + +/// The Montgomery ladder of [`scalar_mult`]: writes the projective u-coordinate of `n * x1` to +/// `(x2, z2)`, for a scalar `n` decoded by [`decode_scalar_25519`]. +// #[inline(never)]: so that the ladder's variables live in a different stack frame and can be popped +// before scalar_mult's `z2.inv()` runs (which is the other stack-expensive operation). +#[inline(never)] +fn ladder( + n: &[u32; 8], + x1: &FieldElement25519, + x2: &mut FieldElement25519, + z2: &mut FieldElement25519, +) { + *x2 = x1.clone(); + *z2 = FieldElement25519::one(); let mut x3 = FieldElement25519::one(); let mut z3 = FieldElement25519::zero(); @@ -705,25 +726,25 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { // B = x_2 - z_2, -> x2 // Must follow the line above, not precede it: that one still reads the incoming z_3, // which this one overwrites. - (z3, x2) = x2.sum_and_difference(&z2); + (z3, *x2) = x2.sum_and_difference(z2); // CB = C * B, -> t1 - t1 = t1.mul(&x2); + t1 = t1.mul(x2); // DA = D * A, -> x3 x3 = x3.mul(&z3); // AA = A^2, -> z3 z3 = z3.sqr(); // BB = B^2, -> x2 - x2 = x2.sqr(); + *x2 = x2.sqr(); // E = AA - BB, -> t2 - t2 = z3.sub(&x2); + t2 = z3.sub(x2); // z_2 = E * (AA + a24 * E), -> z2 // Deviation from the RFC: written as E * (BB + C_A24 * E), an equivalent pairing of the // constant with the other operand. See C_A24's declaration. // The incoming z_2 died on the `sum_and_difference` above, which is what frees this slot. - z2 = t2.mul_u32(C_A24).add(&x2).mul(&t2); + *z2 = t2.mul_u32(C_A24).add(x2).mul(&t2); // x_2 = AA * BB, -> x2 - x2 = x2.mul(&z3); + *x2 = x2.mul(&z3); // x_3 = (DA + CB)^2, -> x3 // z_3 = x_1 * (DA - CB)^2, -> z3 @@ -747,10 +768,10 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { swap ^= k_bit; // (x_2, x_3) = cswap(swap, x_2, x_3) // > swaps in-place - FieldElement25519::cswap(swap, &mut x2, &mut x3); + FieldElement25519::cswap(swap, x2, &mut x3); // (z_2, z_3) = cswap(swap, z_2, z_3) // > swaps in-place - FieldElement25519::cswap(swap, &mut z2, &mut z3); + FieldElement25519::cswap(swap, z2, &mut z3); // swap = k_t swap = k_bit; } @@ -758,13 +779,8 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { debug_assert!(!swap.to_bool()); for _ in 0..3 { - ladder_double(&mut x2, &mut z2); + ladder_double(x2, z2); } - - x2 = x2.mul(&z2.inv()); - let mut out = [0_u8; POINT_SIZE]; - encode_u_coordinate(&x2, &mut out); - X25519PublicKey(out) } fn scalar_mult_base(k: &[u8; SCALAR_SIZE]) -> X25519PublicKey { From f0f45f76aac7b942dbb50e22d95b25e7d80b1657 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 19:53:11 -0500 Subject: [PATCH 22/26] Docs and style tweaks. --- CLAUDE.md | 2 +- QUALITY_AND_STYLE.md | 5 +- cli/src/x25519_cmd.rs | 4 +- crypto/ed/ed_todo.md | 6 +- crypto/ed/src/lib.rs | 12 +- crypto/ed/src/x25519.rs | 168 ++++++++++++++------------ crypto/ed/tests/x25519_dhkem_tests.rs | 12 +- crypto/ed/tests/x25519_key_tests.rs | 4 +- crypto/ed/tests/x25519_tests.rs | 12 +- crypto/ed/tests/x25519_wycheproof.rs | 6 +- 10 files changed, 123 insertions(+), 108 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 908439d61..84c6ff660 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ The workspace has four top-level kinds of member: ### The `core` / `core-test-framework` / `factory` spine -- `crypto/core` defines the abstract traits (`Hash`, `KDF`, `MAC`, `KEM`, `Signature`, `RNG`, `Algorithm`, `HashAlgParams`, …), error enums (`HashError`, `KDFError`, `KEMError`, `MACError`, `RNGError`, `SignatureError`), and the `KeyMaterial` / `KeyType` wrapper that all sensitive byte buffers are required to use (see `Secret` super-trait requirement in QUALITY_AND_STYLE.md). +- `crypto/core` defines the abstract traits (`Hash`, `KDF`, `MAC`, `KEM`, `Signature`, `RNG`, `Algorithm`, `HashAlgParams`, …), error enums (`HashError`, `KDFError`, `KEMError`, `MACError`, `RNGError`, `SignatureError`), and the `KeyMaterial` / `KeyType` wrapper for key bytes. Sensitive data is held in `bouncycastle_utils::secret::Secret`, which `KeyMaterial` is built on (see the sensitive-data rule under "APIs" in QUALITY_AND_STYLE.md). - `crypto/core-test-framework` contains the shared per-trait test suite (`hash.rs`, `kdf.rs`, `kem.rs`, `mac.rs`, `signature.rs`). New implementations of a core trait must be exercised through this framework — it's how trait conformance and error-condition coverage stay consistent across implementations. - `crypto/factory` provides enum-based string-name factories (`HashFactory`, `KDFFactory`, `MACFactory`, `RNGFactory`, `XOFFactory`). Each factory enum impls the underlying trait so it can be used transparently as that primitive, and each impls `AlgorithmFactory` (`new(name)`, `default_128_bit()`, `default_256_bit()`). When adding a new primitive that fits an existing trait, register it in the corresponding factory. diff --git a/QUALITY_AND_STYLE.md b/QUALITY_AND_STYLE.md index 458177530..2574a072f 100644 --- a/QUALITY_AND_STYLE.md +++ b/QUALITY_AND_STYLE.md @@ -20,7 +20,7 @@ a cache and resume later. ## Tests -All crates must have tests in `src/tests`. Part of writing code that treats future maintainers as malicious is that all +All crates must have tests in `tests/`. Part of writing code that treats future maintainers as malicious is that all functions that form part of the public interface should have their expected behaviour fully constrained with tests. In other words, any behaviour change of the library that could cause a change in a calling application should also cause a test in bc-rust to fail. An excellent tool for achieving this is `cargo mutants` which must be run on every crate and @@ -99,7 +99,8 @@ subsequent calls can be made to this object (as opposed to the usual pattern of `do_update(&self, ...)`). These tricks go a long way to reducing fallibility since now in general there is no (or very very little) object state to track and return errors about. -Any struct that holds sensitive data must impl the `core::Secret` trait and all associated super-traits. +Any struct that holds sensitive data must keep it in a `bouncycastle_utils::secret::Secret` field (or in a +`KeyMaterial`, which is built on it), so that it is zeroized on drop and redacted from `Debug` and `Display`. A primitive whose safe use depends on the caller composing it correctly -- a raw block permutation, a raw keystream -- lives under a `hazmat` module in its crate or sub-module, never at the crate root or next to the safe API; diff --git a/cli/src/x25519_cmd.rs b/cli/src/x25519_cmd.rs index 347f60eb3..58f5de0ac 100644 --- a/cli/src/x25519_cmd.rs +++ b/cli/src/x25519_cmd.rs @@ -5,7 +5,7 @@ use bouncycastle::core::key_material::KeyMaterialTrait; use bouncycastle::core::traits::{ KEMDecapsulator, KEMEncapsulator, KeyAgreement, KeyAgreementPrivateKey, KeyAgreementPublicKey, }; -use bouncycastle::ed::x25519::{POINT_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; +use bouncycastle::ed::x25519::{POINT_LEN, X25519, X25519PrivateKey, X25519PublicKey}; use clap::ValueEnum; use std::process::exit; @@ -112,7 +112,7 @@ pub(crate) fn x25519_cmd( }; let ct = read_from_file_or_stdin(ctfile); - if ct.len() != POINT_SIZE { + if ct.len() != POINT_LEN { eprintln!("Error: ciphertext is not the correct length."); exit(-1); } diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 1154ef425..8efa83e38 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -96,12 +96,12 @@ Within each, the priority order is: - [x] Construct from `[u64; 4]` / `Nat<4>` instead of `new(x0..x4)`. Moot: `new` is removed. - [x] Rename `parity` (to `is_odd`?). - [x] Remove the answered "why are we cloning?" TODO. -- [ ] Docs: +- [x] Docs: - [x] Re-enable `#![forbid(missing_docs)]`. - - [ ] Write the crate doc sections required by QUALITY_AND_STYLE.md: + - [x] Write the crate doc sections required by QUALITY_AND_STYLE.md: - [x] Usage Examples (`lib.rs`, linking to the `x25519` module's examples); - [x] Memory Usage (`x25519.rs` module docs); - - [ ] Security Considerations. + - [x] Security Considerations (`x25519.rs` module docs). - [x] Is XDH in a NIST document? No: RFC 7748 only. SP 800-186 specifies the curves but does not list them for key establishment, and SP 800-56A Rev. 3 does not include XDH (`lib.rs` `# Standards`). diff --git a/crypto/ed/src/lib.rs b/crypto/ed/src/lib.rs index eaf5f8c94..b83a66354 100644 --- a/crypto/ed/src/lib.rs +++ b/crypto/ed/src/lib.rs @@ -1,5 +1,7 @@ //! The Edwards curve family includes curve25519 and curve448 which are used to construct //! the EdDSA signature schemes Ed25519 and Ed448, and the XDH key exchange schemes X25519 and X448. +//! The crate name is something of a misnomer, since X25519 and X448 work on the curves' Montgomery +//! form rather than their Edwards form. //! //! # Standards //! @@ -9,15 +11,13 @@ //! * FIPS 186-5 section 7 specifies the EdDSA digital signature algorithms, essentially re-published from RFC 8032. //! * NIST SP 800-186 gives an excellent treatment of the mathematics of all the families of elliptic curves used in cryptography, including Edwards curves. //! -//! XDH (X25519 and X448) is specified only in RFC 7748. NIST SP 800-186 §3.2.2 specifies the -//! curves Curve25519 and Curve448, but its Table 2 does not list them for key establishment, and -//! NIST SP 800-56A Rev. 3 does not include XDH, so X25519 and X448 are not NIST-approved key -//! agreement schemes. +//! Note that NIST SP 800-186 §3.2.2 specifies the curves Curve25519 and Curve448, but its Table 2 +//! does not list them for key establishment, and NIST SP 800-56A Rev. 3 does not include XDH +//! (X25519 / X448), so X25519 and X448 are not NIST-approved key agreement schemes. //! //! # Usage Examples //! -//! See the [`x25519`] module for X25519 key generation and an Alice-and-Bob key agreement that -//! derives a symmetric key from the shared secret. +//! See the [`x25519`] module for X25519 key generation and sample Alice-and-Bob key agreement schemes. #![no_std] #![forbid(unsafe_code)] diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 782637328..6c1952ec3 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -9,11 +9,8 @@ //! ``` //! That will use the library's default OS-backed RNG. To supply your own randomness, use //! [`X25519::keygen_from_rng`], or [`X25519::keygen_from_seed`] with a 32-byte seed, which is also -//! how an existing private key is imported. -//! -//! Private keys can also be generated from a 32-byte seed. The only requirement is that they be -//! 32 bytes long (ie a full [`KeyMaterial256`] or equivalent, -//! [`KeyType::Seed`] or [`KeyType::CryptographicRandom`] and be at least [`SecurityStrength::_128bit`]. +//! how an existing private key is imported. The seed must be a full 32-byte [`KeyMaterial256`] of +//! [`KeyType::Seed`] or [`KeyType::CryptographicRandom`], at [`SecurityStrength::_128bit`] or more. //! //! ```rust //! use bouncycastle_ed::x25519::X25519; @@ -22,13 +19,13 @@ //! //! // Create a seed. //! // We're using a do_hazardous_operations() closure to prevent the library from complaining -//! // that the key material is week. +//! // that the key material is weak. //! let mut seed = KeyMaterial256::new(); //! do_hazardous_operations(&mut seed, |seed| { //! seed.set_bytes_as_type(&[0x02; 32], KeyType::Seed) //! }).unwrap(); //! -//! let (pk, sk) = X25519::keygen().unwrap(); +//! let (pk, sk) = X25519::keygen_from_seed(&seed).unwrap(); //! ``` //! //! ## X25519 key agreement @@ -38,11 +35,9 @@ //! [`X25519::key_agreement`](X25519#method.key_agreement), and both arrive //! at the same shared secret `K`. //! -//! `K` is the 32-byte representation of the U-coordinate of a point on curve25519 -- **its bytes are -//! not uniformly random**, so it should not be used as a key directly: put it through a KDF, -//! along with both public keys (RFC 7748 §6.1). Here that is HKDF-SHA256, with `K` as the input key -//! material and `Alice's public key || Bob's public key` as the context. Both parties must put the -//! public keys in the same order. +//! `K` is then put through a KDF (see [Security Considerations](#-security-considerations-)). Here +//! that is HKDF-SHA256, with `K` as the input key material and `Alice's public key || Bob's public +//! key` as the context. Both parties must put the public keys in the same order. //! //! ```rust //! use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; @@ -196,7 +191,30 @@ //! //! Key generation, decoding a private key, and key agreement each run one Montgomery ladder plus //! the field inversion. -//! The KEM figures additionally run HPKE.DHKEM and stack usage is dominated by the HKDF-SHA256 key schedule. +//! The KEM figures additionally run HPKE.DHKEM and stack usage is dominated by the HKDF-SHA256 key +//! schedule. +//! +//! # 🚨 Security Considerations 🚨 +//! +//! **A raw shared secret is not a key**. [`X25519::key_agreement`](X25519#method.key_agreement) +//! returns the shared secret `K` as an [`X25519PublicKey`]: the 32-byte encoding of the +//! u-coordinate of a point on curve25519. Those bytes are not uniformly random, and therefore not +//! suitable for direct use as a symmetric key. +//! The encoded shared key MUST be run through a KDF such as HMAC-SHA256, HKDF-SHA256, or SHAKE. +//! +//! RFC 7748 §7: "for each public key, there are several publicly computable public keys that are +//! equivalent to it, i.e., they produce the same shared secrets. Thus using a public key as an +//! identifier and knowledge of a shared secret as proof of ownership (without including the public +//! keys in the key derivation) might lead to subtle vulnerabilities". +//! This is the reason for including both public keys in the key derivation step. +//! Note that the HPKE DHKEM exposed via the [`KEMEncapsulator`] / [`KEMDecapsulator`] traits protects +//! against this. +//! +//! RFC 9180 §9.7.4: "HPKE ciphertexts are not forward secret with respect to recipient compromise +//! in any mode". +//! Anyone who later obtains a long-term DHKEM private key can decrypt everything ever sent to it. +//! This equally applies to most uses of the raw [`KeyAgreement`] interface, unless a suitable protocol +//! is layered on top. use bouncycastle_core::errors::{KEMError, KeyAgreementError, RNGError}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterial256, KeyMaterialTrait, KeyType}; @@ -223,17 +241,17 @@ use crate::curve25519::FieldElement25519; /// Size in bytes of the encoded representation of a point on curve25519, /// which are used as public keys. -pub const POINT_SIZE: usize = 32; +pub const POINT_LEN: usize = 32; /// Size in bytes of the encoded representation of a scalar in the curve25519 field, /// which are used as private keys. -pub const SCALAR_SIZE: usize = 32; +pub const SCALAR_LEN: usize = 32; /// String name of the X25519 algorithm. pub const X25519_NAME: &str = "X25519"; /// The base point of curve25519, `U(P) = 9` (Section 4.1 of RFC7748). const BASE_POINT: X25519PublicKey = { - let mut u = [0_u8; POINT_SIZE]; + let mut u = [0_u8; POINT_LEN]; u[0] = 9; X25519PublicKey(u) }; @@ -291,7 +309,7 @@ impl X25519 { seed: &KeyMaterial256, ) -> Result<(X25519PublicKey, X25519PrivateKey), KeyAgreementError> { if !(seed.key_type() == KeyType::Seed || seed.key_type() == KeyType::CryptographicRandom) - || seed.key_len() != SCALAR_SIZE + || seed.key_len() != SCALAR_LEN { return Err(KeyAgreementError::KeyGenError( "Seed must be 32 bytes and KeyType::Seed or KeyType::CryptographicRandom.", @@ -304,7 +322,7 @@ impl X25519 { )); } - let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + let mut k: Secret<[u8; SCALAR_LEN]> = Secret::new(); // Cannot panic: the length was checked above. k.copy_from_slice(seed.ref_to_bytes()); let sk = X25519PrivateKey { pk: scalar_mult_base(&k), k }; @@ -323,7 +341,7 @@ impl X25519 { } } -impl KeyAgreement for X25519 { +impl KeyAgreement for X25519 { /// The X25519 Diffie-Hellman of RFC 7748 §6.1: computes the shared secret /// `K = X25519(sk, pk)`, the u-coordinate of the scalar multiple of the peer's public key by /// our private key, as its canonical 32-byte little-endian encoding. @@ -338,19 +356,17 @@ impl KeyAgreement fo /// /// # Errors /// - /// Returns [`KeyAgreementError::InvalidPublicKey`] if `K` is all zeros. RFC 7748 §6.1: "The - /// check for the all-zero value results from the fact that the X25519 function produces that - /// value if it operates on an input corresponding to a point with small order". Such a point - /// "will eliminate any contribution from the other party's private key" (§7), so `K` is the + /// The only error condition ([`KeyAgreementError::InvalidPublicKey`]) on key_agreement happens + /// when the input public key is part of the curve's small subgroup (which will almost always be + /// done maliciously in practice). + /// That results in an output `K` that is all zeros. Such a point + /// "will eliminate any contribution from the other party's private key" (RFC 7748 §7), so `K` is the /// same for every private key and is known to whoever chose `pk`. /// /// RFC 7748 makes the check optional: both parties "MAY check, without leaking extra - /// information about the value of K, whether K is the all-zero value and abort if so". This - /// implementation always performs it, as the protocols built on X25519 require: - /// * RFC 8446 (TLS 1.3) §7.4.2: "Implementations MUST check whether the computed - /// Diffie-Hellman shared secret is the all-zero value and abort if so". - /// * RFC 9180 (HPKE) §7.1.4: "recipients MUST check whether the Diffie-Hellman shared secret - /// is the all-zero value and abort if so". + /// information about the value of K, whether K is the all-zero value and abort if so". + /// This implementation always performs it and returns [`KeyAgreementError::InvalidPublicKey`] + /// if `K` is all zeros. /// /// The check is constant time, ORing all the bytes together as §6.1 suggests, so the only /// information it reveals about `K` is whether it is zero. @@ -384,7 +400,7 @@ fn dh(sk: &X25519PrivateKey, pk: &X25519PublicKey) -> Result, + k: Secret<[u8; SCALAR_LEN]>, pk: X25519PublicKey, } @@ -407,7 +423,7 @@ impl X25519PrivateKey { } /// The key bytes, [`clamp`]ed. - fn clamped(&self) -> Secret<[u8; SCALAR_SIZE]> { + fn clamped(&self) -> Secret<[u8; SCALAR_LEN]> { let mut k = self.k.clone(); clamp(&mut k); k @@ -421,7 +437,7 @@ impl X25519PrivateKey { /// k_list[31] &= 127 /// k_list[31] |= 64 /// ``` -fn clamp(k: &mut [u8; SCALAR_SIZE]) { +fn clamp(k: &mut [u8; SCALAR_LEN]) { k[0] &= 248; k[31] &= 127; k[31] |= 64; @@ -431,15 +447,15 @@ fn clamp(k: &mut [u8; SCALAR_SIZE]) { /// §5), so keys that compare equal have the same encoding. This is also the DHKEM serialization: /// RFC 9180 §7.1.2 says "The SerializePrivateKey() function MUST clamp its output and the /// DeserializePrivateKey() function MUST clamp its input". -impl KeyAgreementPrivateKey for X25519PrivateKey { - fn encode(&self) -> [u8; SCALAR_SIZE] { +impl KeyAgreementPrivateKey for X25519PrivateKey { + fn encode(&self) -> [u8; SCALAR_LEN] { *self.clamped() } - fn encode_out(&self, out: &mut [u8; SCALAR_SIZE]) -> usize { + fn encode_out(&self, out: &mut [u8; SCALAR_LEN]) -> usize { out.fill(0); *out = *self.clamped(); - SCALAR_SIZE + SCALAR_LEN } /// Any 32 bytes are a valid private key; they are clamped on the way in. @@ -448,10 +464,10 @@ impl KeyAgreementPrivateKey for X25519PrivateKey { /// /// [`KeyAgreementError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - let bytes: &[u8; SCALAR_SIZE] = bytes.try_into().map_err(|_| { + let bytes: &[u8; SCALAR_LEN] = bytes.try_into().map_err(|_| { KeyAgreementError::DecodingError("An X25519 private key must be 32 bytes") })?; - let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + let mut k: Secret<[u8; SCALAR_LEN]> = Secret::new(); *k = *bytes; clamp(&mut k); Ok(Self { pk: scalar_mult_base(&k), k }) @@ -473,11 +489,11 @@ impl Eq for X25519PrivateKey {} /// Any 32 bytes are accepted: Section 5 requires bit 255 to be ignored and values `>= p` to be /// processed as if reduced mod p. Public data, so it is `Copy` and its `Debug` shows the bytes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct X25519PublicKey([u8; POINT_SIZE]); +pub struct X25519PublicKey([u8; POINT_LEN]); impl X25519PublicKey { /// The 32 encoded bytes. - pub const fn as_bytes(&self) -> &[u8; POINT_SIZE] { + pub const fn as_bytes(&self) -> &[u8; POINT_LEN] { &self.0 } @@ -492,15 +508,15 @@ impl X25519PublicKey { } /// The encoding is the 32 bytes of the u-coordinate, as is (RFC 7748 §5). -impl KeyAgreementPublicKey for X25519PublicKey { - fn encode(&self) -> [u8; POINT_SIZE] { +impl KeyAgreementPublicKey for X25519PublicKey { + fn encode(&self) -> [u8; POINT_LEN] { self.0 } - fn encode_out(&self, out: &mut [u8; POINT_SIZE]) -> usize { + fn encode_out(&self, out: &mut [u8; POINT_LEN]) -> usize { out.fill(0); *out = self.0; - POINT_SIZE + POINT_LEN } /// Any 32 bytes are accepted: RFC 7748 §5 requires bit 255 to be ignored and values `>= p` to @@ -510,7 +526,7 @@ impl KeyAgreementPublicKey for X25519PublicKey { /// /// [`KeyAgreementError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - let bytes: &[u8; POINT_SIZE] = bytes.try_into().map_err(|_| { + let bytes: &[u8; POINT_LEN] = bytes.try_into().map_err(|_| { KeyAgreementError::DecodingError("An X25519 public key must be 32 bytes") })?; Ok(Self(*bytes)) @@ -565,7 +581,7 @@ impl Display for X25519PublicKey { /// /// Input: A 32-byte little-endian scalar. /// Output: The decoded scalar, written to `n` as eight little-endian 32-bit words. -fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { +fn decode_scalar_25519(k: &[u8; SCALAR_LEN], n: &mut [u32; 8]) { let (words, _) = k.as_chunks::<4>(); // 32 bytes: exactly 8 chunks, no remainder for (n_i, w) in n.iter_mut().zip(words) { *n_i = u32::from_le_bytes(*w); @@ -596,8 +612,6 @@ fn decode_scalar_25519(k: &[u8; SCALAR_SIZE], n: &mut [u32; 8]) { /// non-canonical values and process them as if they had been reduced modulo the field prime. The /// non-canonical values are 2^255 - 19 through 2^255 - 1 for X25519". /// -/// Verified equal to `decodeUCoordinate(b, 255)` over 20,004 inputs, random plus edge cases. -/// /// Input: A 32-byte little-endian u-coordinate. /// Output: The u-coordinate as a [`FieldElement25519`], not normalized. fn decode_u_coordinate(u: &X25519PublicKey) -> FieldElement25519 { @@ -612,7 +626,7 @@ fn decode_u_coordinate(u: &X25519PublicKey) -> FieldElement25519 { /// /// Input: A u-coordinate, in any state of reduction; a 32-byte output buffer. /// Output: The canonical 32-byte little-endian encoding of that value, written to `r`. -fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_SIZE]) { +fn encode_u_coordinate(u: &FieldElement25519, r: &mut [u8; POINT_LEN]) { u.normalize().encode_255(r); } @@ -657,7 +671,7 @@ fn ladder_double(x: &mut FieldElement25519, z: &mut FieldElement25519) { /// * `k`, a 32-byte scalar /// * `u`, a 32-byte little-endian u-coordinate /// Output: The canonical little-endian encoding of the u-coordinate of `k * u`. -fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { +fn scalar_mult(k: &[u8; SCALAR_LEN], u: &X25519PublicKey) -> X25519PublicKey { // The decoded scalar is as secret as `k` itself. let mut n: Secret<[u32; 8]> = Secret::new(); decode_scalar_25519(k, &mut n); @@ -674,7 +688,7 @@ fn scalar_mult(k: &[u8; SCALAR_SIZE], u: &X25519PublicKey) -> X25519PublicKey { ladder(&n, &x1, &mut x2, &mut z2); x2 = x2.mul(&z2.inv()); - let mut out = [0_u8; POINT_SIZE]; + let mut out = [0_u8; POINT_LEN]; encode_u_coordinate(&x2, &mut out); X25519PublicKey(out) } @@ -783,7 +797,7 @@ fn ladder( } } -fn scalar_mult_base(k: &[u8; SCALAR_SIZE]) -> X25519PublicKey { +fn scalar_mult_base(k: &[u8; SCALAR_LEN]) -> X25519PublicKey { // Equivalent (but much slower) -- since the BASE_POINT never changes, you can // precompute lookup tables for the multiples of BASE_POINT. TODO scalar_mult(k, &BASE_POINT) @@ -798,10 +812,10 @@ fn scalar_mult_base(k: &[u8; SCALAR_SIZE]) -> X25519PublicKey { pub const DHKEM_SS_LEN: usize = 32; /// DHKEM(X25519, HKDF-SHA256) of RFC 9180 §4.1. Nenc = Npk = Ndh = 32 and Nsk = 32 (§7.1). -type X25519DHKEM = DHKEM; +type X25519DHKEM = DHKEM; /// DHKEM(X25519, HKDF-SHA256): kem_id 0x0020, with Nenc = Npk = Nsk = 32 (RFC 9180 §7.1). -impl DHKEMParams for X25519 { +impl DHKEMParams for X25519 { type PublicKey = X25519PublicKey; type PrivateKey = X25519PrivateKey; type KDFHash = SHA256; @@ -819,8 +833,8 @@ impl DHKEMParams for X25519 { }) } - fn private_key_from_bytes(sk: &[u8; SCALAR_SIZE]) -> X25519PrivateKey { - let mut k: Secret<[u8; SCALAR_SIZE]> = Secret::new(); + fn private_key_from_bytes(sk: &[u8; SCALAR_LEN]) -> X25519PrivateKey { + let mut k: Secret<[u8; SCALAR_LEN]> = Secret::new(); *k = *sk; X25519PrivateKey { pk: scalar_mult_base(&k), k } } @@ -832,19 +846,19 @@ impl DHKEMParams for X25519 { fn dh( sk: &X25519PrivateKey, pk: &X25519PublicKey, - ) -> Result, KeyAgreementError> { - let mut k: Secret<[u8; POINT_SIZE]> = Secret::new(); + ) -> Result, KeyAgreementError> { + let mut k: Secret<[u8; POINT_LEN]> = Secret::new(); *k = dh(sk, pk)?.0; Ok(k) } // RFC 9180 §7.1.1: "For X25519 and X448, the SerializePublicKey() and DeserializePublicKey() // functions are the identity function". - fn serialize_public_key(pk: &X25519PublicKey) -> [u8; POINT_SIZE] { + fn serialize_public_key(pk: &X25519PublicKey) -> [u8; POINT_LEN] { pk.0 } - fn deserialize_public_key(pkm: &[u8; POINT_SIZE]) -> Result { + fn deserialize_public_key(pkm: &[u8; POINT_LEN]) -> Result { Ok(X25519PublicKey(*pkm)) } } @@ -852,7 +866,7 @@ impl DHKEMParams for X25519 { /// X25519 as a KEM: DHKEM(X25519, HKDF-SHA256) of RFC 9180 §4.1, where the ciphertext is an /// ephemeral public key (`enc`) and the shared secret is derived with HKDF-SHA256 from the X25519 /// output and both public keys. -impl KEMEncapsulator for X25519 { +impl KEMEncapsulator for X25519 { /// `Encap(pkR)` (RFC 9180 §4.1), drawing the ephemeral key pair from the library's default RNG, /// seeded from the OS. /// @@ -862,7 +876,7 @@ impl KEMEncapsulator for /// [`X25519::key_agreement`]. fn encaps( pk: &X25519PublicKey, - ) -> Result<(KeyMaterial, [u8; POINT_SIZE]), KEMError> { + ) -> Result<(KeyMaterial, [u8; POINT_LEN]), KEMError> { let mut os_rng = HashDRBG_SHA512::new_from_os(); Self::encaps_rng(pk, &mut os_rng) } @@ -877,12 +891,12 @@ impl KEMEncapsulator for fn encaps_rng( pk: &X25519PublicKey, rng: &mut dyn RNG, - ) -> Result<(KeyMaterial, [u8; POINT_SIZE]), KEMError> { + ) -> Result<(KeyMaterial, [u8; POINT_LEN]), KEMError> { X25519DHKEM::encap_rng(pk, rng) } } -impl KEMDecapsulator for X25519 { +impl KEMDecapsulator for X25519 { /// `Decap(enc, skR)` (RFC 9180 §4.1), with the ciphertext `ct` as `enc`. /// /// # Errors @@ -890,7 +904,7 @@ impl KEMDecapsulator fo /// * [`KEMError::LengthError`] if `ct` is not 32 bytes. /// * [`KEMError::DecapsulationFailed`] if `ct` gives an all-zero X25519 output. fn decaps(sk: &X25519PrivateKey, ct: &[u8]) -> Result, KEMError> { - let enc: &[u8; POINT_SIZE] = ct + let enc: &[u8; POINT_LEN] = ct .try_into() .map_err(|_| KEMError::LengthError("An X25519 KEM ciphertext must be 32 bytes"))?; X25519DHKEM::decap(enc, sk) @@ -899,40 +913,40 @@ impl KEMDecapsulator fo /// The same clamped encoding as the [`KeyAgreementPrivateKey`] impl, which RFC 9180 §7.1.2 requires /// of a DHKEM. -impl KEMPrivateKey for X25519PrivateKey { - fn encode(&self) -> [u8; SCALAR_SIZE] { - >::encode(self) +impl KEMPrivateKey for X25519PrivateKey { + fn encode(&self) -> [u8; SCALAR_LEN] { + >::encode(self) } - fn encode_out(&self, out: &mut [u8; SCALAR_SIZE]) -> usize { - >::encode_out(self, out) + fn encode_out(&self, out: &mut [u8; SCALAR_LEN]) -> usize { + >::encode_out(self, out) } /// # Errors /// /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - >::from_bytes(bytes) + >::from_bytes(bytes) .map_err(|_| KEMError::DecodingError("An X25519 private key must be 32 bytes")) } } /// The same encoding as the [`KeyAgreementPublicKey`] impl: RFC 9180 §7.1.1 makes serialization the /// identity function for X25519. -impl KEMPublicKey for X25519PublicKey { - fn encode(&self) -> [u8; POINT_SIZE] { - >::encode(self) +impl KEMPublicKey for X25519PublicKey { + fn encode(&self) -> [u8; POINT_LEN] { + >::encode(self) } - fn encode_out(&self, out: &mut [u8; POINT_SIZE]) -> usize { - >::encode_out(self, out) + fn encode_out(&self, out: &mut [u8; POINT_LEN]) -> usize { + >::encode_out(self, out) } /// # Errors /// /// [`KEMError::DecodingError`] if `bytes` is not 32 bytes. fn from_bytes(bytes: &[u8]) -> Result { - >::from_bytes(bytes) + >::from_bytes(bytes) .map_err(|_| KEMError::DecodingError("An X25519 public key must be 32 bytes")) } } diff --git a/crypto/ed/tests/x25519_dhkem_tests.rs b/crypto/ed/tests/x25519_dhkem_tests.rs index 9a8d28c70..caf8f2ab8 100644 --- a/crypto/ed/tests/x25519_dhkem_tests.rs +++ b/crypto/ed/tests/x25519_dhkem_tests.rs @@ -10,12 +10,12 @@ use bouncycastle_core::traits::{ use bouncycastle_core_test_framework::FixedSeedRNG; use bouncycastle_core_test_framework::kem::{TestFrameworkKEM, TestFrameworkKEMKeys}; use bouncycastle_ed::x25519::{ - DHKEM_SS_LEN, POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey, + DHKEM_SS_LEN, POINT_LEN, SCALAR_LEN, X25519, X25519PrivateKey, X25519PublicKey, }; use bouncycastle_hex as hex; use bouncycastle_hpke::dhkem::DHKEM; -type X25519DHKEM = DHKEM; +type X25519DHKEM = DHKEM; /// The DHKEM values of one RFC 9180 Appendix A.1 vector set. struct Vector { @@ -212,9 +212,9 @@ fn kem_framework() { X25519PublicKey, X25519PrivateKey, X25519, - POINT_SIZE, - SCALAR_SIZE, - POINT_SIZE, + POINT_LEN, + SCALAR_LEN, + POINT_LEN, DHKEM_SS_LEN, >(keygen, false); } @@ -222,7 +222,7 @@ fn kem_framework() { #[test] fn kem_keys_framework() { TestFrameworkKEMKeys::new() - .test_keys::(keygen); + .test_keys::(keygen); } /// Low-order public keys give an all-zero X25519 output, which RFC 9180 Section 7.1.4 requires diff --git a/crypto/ed/tests/x25519_key_tests.rs b/crypto/ed/tests/x25519_key_tests.rs index df7611c21..d8393e648 100644 --- a/crypto/ed/tests/x25519_key_tests.rs +++ b/crypto/ed/tests/x25519_key_tests.rs @@ -3,7 +3,7 @@ use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType} use bouncycastle_core::security_strength::SecurityStrength; use bouncycastle_core::traits::{KeyAgreementPrivateKey, KeyAgreementPublicKey}; use bouncycastle_core_test_framework::FixedSeedRNG; -use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; +use bouncycastle_ed::x25519::{POINT_LEN, SCALAR_LEN, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; /// RFC 7748 Section 6.1: Alice's private key a and public key X25519(a, 9). @@ -20,7 +20,7 @@ fn core_framework_tests() { let tf = TestFrameworkKeyAgreementKeys::new(); - tf.test_keys::(X25519::keygen); + tf.test_keys::(X25519::keygen); } #[test] diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index 25a5d8099..d23ccc5fb 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -1,7 +1,7 @@ use bouncycastle_core::errors::KeyAgreementError; use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; use bouncycastle_core::traits::{KeyAgreement, KeyAgreementPublicKey}; -use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; +use bouncycastle_ed::x25519::{POINT_LEN, SCALAR_LEN, X25519, X25519PrivateKey, X25519PublicKey}; use bouncycastle_hex as hex; #[test] @@ -10,7 +10,7 @@ fn core_framework_tests() { let tf = TestFrameworkKeyAgreement::new(); - tf.test_key_agreement::( + tf.test_key_agreement::( X25519::keygen, ); } @@ -88,7 +88,7 @@ fn rfc7748_section_6_1() { /// bit 255 set. #[test] fn key_agreement_rejects_all_zero_shared_secret() { - let zero = [0_u8; POINT_SIZE]; + let zero = [0_u8; POINT_LEN]; let p = decode_32("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"); let mut zero_bit_255 = zero; zero_bit_255[31] = 0x80; @@ -121,11 +121,11 @@ fn sk_from_bytes(bytes: &[u8]) -> X25519PrivateKey { /// arithmetic and encodings on `count` effectively random inputs against a single known answer, /// and one wrong step corrupts every value after it. The single-call vectors cover two inputs. fn rfc7748_check_iterated(count: usize) { - assert_eq!(POINT_SIZE, SCALAR_SIZE); + assert_eq!(POINT_LEN, SCALAR_LEN); - let mut k = [0_u8; POINT_SIZE]; + let mut k = [0_u8; POINT_LEN]; k[0] = 9; - let mut u = [0_u8; POINT_SIZE]; + let mut u = [0_u8; POINT_LEN]; u[0] = 9; let mut iterations = 0; while iterations < count { diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index 740ab4816..1ffb016e1 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -15,7 +15,7 @@ use bouncycastle_core::errors::KeyAgreementError; use bouncycastle_core::key_material::{KeyMaterial256, KeyType}; use bouncycastle_core::traits::{KeyAgreement, KeyAgreementPublicKey}; use bouncycastle_core_test_framework::test_data_loaders::{Value, hex_field, wycheproof_json}; -use bouncycastle_ed::x25519::{POINT_SIZE, SCALAR_SIZE, X25519, X25519PrivateKey, X25519PublicKey}; +use bouncycastle_ed::x25519::{POINT_LEN, SCALAR_LEN, X25519, X25519PrivateKey, X25519PublicKey}; #[test] fn wycheproof_x25519() { @@ -37,9 +37,9 @@ fn wycheproof_x25519() { other => panic!("{ctx}: unexpected result {other}"), } - let k: [u8; SCALAR_SIZE] = + let k: [u8; SCALAR_LEN] = hex_field(test, "private", tc_id).try_into().expect("a 32-byte private key"); - let u: [u8; POINT_SIZE] = + let u: [u8; POINT_LEN] = hex_field(test, "public", tc_id).try_into().expect("a 32-byte public key"); let shared = hex_field(test, "shared", tc_id); From 270fac8e09d070f32b2ab21c8b7e13d1fdcc726a Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 19:59:14 -0500 Subject: [PATCH 23/26] DONE X25519!! --- crypto/ed/ed_todo.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crypto/ed/ed_todo.md b/crypto/ed/ed_todo.md index 8efa83e38..d82f8355a 100644 --- a/crypto/ed/ed_todo.md +++ b/crypto/ed/ed_todo.md @@ -118,6 +118,10 @@ Within each, the priority order is: - [x] Split the ladder out of `scalar_mult` into an `#[inline(never)]` `ladder`, so the field inversion no longer runs on top of the ladder's registers: -416 B on KeyGen, KeyAgreement and private-key decoding, no speed cost. Encaps/Decaps unchanged (HKDF sets their peak). +- [ ] Fast fixed-base `scalar_mult_base` (TODO in `x25519.rs`). Options: an Edwards comb table as + BC Java does (`Ed25519.scalarMultBaseYZ`, then u = (Z+Y)/(Z−Y); ~7.7 KB rodata, est. 2.5–3x + faster keygen, needs the Ed25519 point arithmetic), or a ladder specialised to u = 9 (no table, + est. ~10%). Probably best done with the Ed25519 phase's fixed-base comb tables. - [x] Rethink the name of this crate: `bouncycastle-ed` is kindo the wrong name (but maybe nothing better exists?) --- From 11a04ecaa70f5747f1881d44eb15dce4796f179c Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 20:17:52 -0500 Subject: [PATCH 24/26] doc tweak --- crypto/core/src/traits.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 938201aab..2e9941735 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1098,7 +1098,7 @@ pub trait KeyAgreement< /// Note that this returns a public key. The caller is responsible for using a [`KDF`] to /// derive a shared symmetric key from it. /// Alternatively, most algorithms that impl [`KeyAgreement`] should also impl - /// [`KEMEncapsulator`] / [`KEMDecapsulator`] + /// [`KEMEncapsulator`] / [`KEMDecapsulator`], which will include a KDF step and return a [`KeyMaterial`]. fn key_agreement(pk: &PK, sk: &SK) -> Result; } From 78ab66f0ac7be2b12f0a581a57970f42d7ac2e4f Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 21:21:42 -0500 Subject: [PATCH 25/26] Suggestions from Nicola Tuveri: have a separate type for the DerivedKey returned from key_agreement(). --- .../core-test-framework/src/key_agreement.rs | 16 ++--- crypto/core/src/traits.rs | 4 +- crypto/ed/src/x25519.rs | 59 +++++++++++++++---- crypto/ed/tests/x25519_tests.rs | 11 ++-- crypto/ed/tests/x25519_wycheproof.rs | 2 +- .../src/bench_x25519_mem_usage.rs | 2 +- 6 files changed, 66 insertions(+), 28 deletions(-) diff --git a/crypto/core-test-framework/src/key_agreement.rs b/crypto/core-test-framework/src/key_agreement.rs index d4063279e..fa21d3b2f 100644 --- a/crypto/core-test-framework/src/key_agreement.rs +++ b/crypto/core-test-framework/src/key_agreement.rs @@ -27,7 +27,9 @@ impl TestFrameworkKeyAgreement { >( &self, keygen: fn() -> Result<(PK, SK), KeyAgreementError>, - ) { + ) where + KAAlg::DerivedKey: AsRef<[u8]>, + { let (pk_a, sk_a) = keygen().unwrap(); let (pk_b, sk_b) = keygen().unwrap(); @@ -35,25 +37,25 @@ impl TestFrameworkKeyAgreement { // KA(&pk_a, &sk_b) == KA(&pk_b, &sk_a) let k_ab = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); let k_ba = KAAlg::key_agreement(&pk_b, &sk_a).unwrap(); - assert!(k_ba.eq(&k_ab)); + assert_eq!(k_ba.as_ref(), k_ab.as_ref()); // Determinism // The same (pk, sk) twice gives the same output let k_1 = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); let k_2 = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); - assert!(k_1.eq(&k_2)); + assert_eq!(k_1.as_ref(), k_2.as_ref()); - // Output is a usable PK + // Output decodes as a usable PK let k = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); - _ = KAAlg::key_agreement(&k, &sk_a).unwrap(); + _ = KAAlg::key_agreement(&PK::from_bytes(k.as_ref()).unwrap(), &sk_a).unwrap(); // Decoded keys behave the same // KA on PK::from_bytes(&pk.encode()) and SK::from_bytes(&sk.encode()) equals KA on the originals let k = KAAlg::key_agreement(&pk_a, &sk_b).unwrap(); let k1 = KAAlg::key_agreement(&PK::from_bytes(&pk_a.encode()).unwrap(), &sk_b).unwrap(); - assert!(k.eq(&k1)); + assert_eq!(k.as_ref(), k1.as_ref()); let k2 = KAAlg::key_agreement(&pk_a, &SK::from_bytes(&sk_b.encode()).unwrap()).unwrap(); - assert!(k.eq(&k2)); + assert_eq!(k.as_ref(), k2.as_ref()); } } diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 2e9941735..1a674f477 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -1094,12 +1094,14 @@ pub trait KeyAgreement< const SK_LEN: usize, >: Sized { + /// The type of the derived key that results from performing a key agreement. + type DerivedKey: Sized; /// Perform a key agreement. /// Note that this returns a public key. The caller is responsible for using a [`KDF`] to /// derive a shared symmetric key from it. /// Alternatively, most algorithms that impl [`KeyAgreement`] should also impl /// [`KEMEncapsulator`] / [`KEMDecapsulator`], which will include a KDF step and return a [`KeyMaterial`]. - fn key_agreement(pk: &PK, sk: &SK) -> Result; + fn key_agreement(pk: &PK, sk: &SK) -> Result; } /// A public key for a KeyAgreement (Diffie-Hellman) algorithm, often denoted "pk". diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 6c1952ec3..6e694bb3b 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -43,7 +43,7 @@ //! use bouncycastle_core::key_material::{KeyMaterial256, KeyMaterialTrait, KeyType}; //! use bouncycastle_core::security_strength::SecurityStrength; //! use bouncycastle_core::traits::{KDF, KeyAgreement}; -//! use bouncycastle_ed::x25519::{X25519, X25519PublicKey}; +//! use bouncycastle_ed::x25519::{X25519, X25519DerivedKey, X25519PublicKey}; //! use bouncycastle_sha2::hkdf::HKDF_SHA256; //! //! // Alice and Bob each generate a key pair, and exchange public keys. @@ -52,8 +52,7 @@ //! //! // Each performs a key agreement from their own private key and the other's public key, //! // each arriving at the same k. -//! // Note that k is an X25519PublicKey and could be used again for another key agreement. -//! // But this example will proceed with deriving a shared symmetric key. +//! // k is an X25519DerivedKey, a secret; this example derives a shared symmetric key from it. //! // An error here means the other party's public key is unusable; abort the exchange. //! let alice_k = X25519::key_agreement(&bob_pk, &alice_sk).expect("Bad public key"); //! let bob_k = X25519::key_agreement(&alice_pk, &bob_sk).expect("Bad public key"); @@ -61,7 +60,7 @@ //! // RFC 7748 §6.1 recommends using a key derivation function (KDF) over K, K_A, and K_B //! // to derive a symmetric key. This example will use HKDF_SHA256. //! fn derive_key_rfc7748( -//! k: &X25519PublicKey, +//! k: &X25519DerivedKey, //! pk_A: &X25519PublicKey, //! pk_B: &X25519PublicKey) -> KeyMaterial256 { //! let mut ikm = @@ -145,18 +144,18 @@ //! let bc_k = X25519::key_agreement(&charlie_pk, &bob_sk).expect("Bad public key"); //! let ac_k = X25519::key_agreement(&charlie_pk, &alice_sk).expect("Bad public key"); //! -//! // Round 2: each pairwise value is sent, in the clear, to the third party, who combines it with -//! // their own private key. +//! // Round 2: each pairwise value is converted to a public key type and sent, in the clear, +//! // to the third party, who combines it with their own private key. //! // Since it is no longer secret, `ab_k` must not also be used as a secret key between //! // Alice and Bob. -//! let alice_k = X25519::key_agreement(&bc_k, &alice_sk).expect("Bad public key"); -//! let bob_k = X25519::key_agreement(&ac_k, &bob_sk).expect("Bad public key"); -//! let charlie_k = X25519::key_agreement(&ab_k, &charlie_sk).expect("Bad public key"); +//! let alice_k = X25519::key_agreement(&bc_k.as_pk(), &alice_sk).expect("Bad public key"); +//! let bob_k = X25519::key_agreement(&ac_k.as_pk(), &bob_sk).expect("Bad public key"); +//! let charlie_k = X25519::key_agreement(&ab_k.as_pk(), &charlie_sk).expect("Bad public key"); //! //! // The computations ran in different orders, but by the magic of //! // commutative algebra, they all end up with the same shared secret key `k`. -//! assert!(alice_k.eq(&bob_k)); -//! assert!(bob_k.eq(&charlie_k)); +//! assert_eq!(alice_k.as_ref(), bob_k.as_ref()); +//! assert_eq!(bob_k.as_ref(), charlie_k.as_ref()); //! //! // As in the two-party case, `k` is not uniformly random: run it through a suitable KDF scheme //! // to derive a usable and secure symmetric key. @@ -342,6 +341,8 @@ impl X25519 { } impl KeyAgreement for X25519 { + type DerivedKey = X25519DerivedKey; + /// The X25519 Diffie-Hellman of RFC 7748 §6.1: computes the shared secret /// `K = X25519(sk, pk)`, the u-coordinate of the scalar multiple of the peer's public key by /// our private key, as its canonical 32-byte little-endian encoding. @@ -373,8 +374,8 @@ impl KeyAgreement for fn key_agreement( pk: &X25519PublicKey, sk: &X25519PrivateKey, - ) -> Result { - dh(sk, pk) + ) -> Result { + dh(sk, pk).map(X25519DerivedKey::from) } } @@ -550,6 +551,38 @@ impl Display for X25519PublicKey { } } +/// The shared secret `K` from [`X25519::key_agreement`](X25519#method.key_agreement). +/// This impls `AsRef<[u8]>` so the 32 bytes can be freely extracted. +/// Structurally, this is an X25519PublicKey, but it is wrapped in its own type to protect it with +/// [`Secret`]. If it needs to be treated as a public key, use [`X25519DerivedKey::as_pk`]. +/// +/// Note that this impls `From for X25519DerivedKey` so that any public key can be +/// freely converted into a DerivedKey type. +pub struct X25519DerivedKey(Secret<[u8; POINT_LEN]>); + +impl X25519DerivedKey { + /// Opt-in to treating this as a public key. + pub fn as_pk(&self) -> X25519PublicKey { + X25519PublicKey(*self.0) + } +} + +impl AsRef<[u8]> for X25519DerivedKey { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +/// Allow an X25519PublicKey to auto-promote to an X25519DerivedKey. +/// It's never a problem to treat a public value as secret. +impl From for X25519DerivedKey { + fn from(value: X25519PublicKey) -> Self { + let mut dk = Secret::<[u8; POINT_LEN]>::new(); + dk.copy_from_slice(value.as_bytes()); + Self(dk) + } +} + /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: /// /// ```text diff --git a/crypto/ed/tests/x25519_tests.rs b/crypto/ed/tests/x25519_tests.rs index d23ccc5fb..cb4aa0be9 100644 --- a/crypto/ed/tests/x25519_tests.rs +++ b/crypto/ed/tests/x25519_tests.rs @@ -79,8 +79,8 @@ fn rfc7748_section_6_1() { let k = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"; let alice_k = X25519::key_agreement(&bob_pk, &alice_sk).unwrap(); let bob_k = X25519::key_agreement(&alice_pk, &bob_sk).unwrap(); - check_value(alice_k.as_bytes(), "Section 6.1 K (Alice)", k); - check_value(bob_k.as_bytes(), "Section 6.1 K (Bob)", k); + check_value(alice_k.as_ref(), "Section 6.1 K (Alice)", k); + check_value(bob_k.as_ref(), "Section 6.1 K (Bob)", k); } /// [`X25519::key_agreement`] rejects a public key that gives an all-zero shared secret, however @@ -99,7 +99,8 @@ fn key_agreement_rejects_all_zero_shared_secret() { )); match X25519::key_agreement(&X25519PublicKey::from_bytes(&u).unwrap(), &sk) { Err(KeyAgreementError::InvalidPublicKey(_)) => { /* good */ } - other => panic!("expected InvalidPublicKey for u = {u:02x?}, got {other:?}"), + Err(e) => panic!("expected InvalidPublicKey for u = {u:02x?}, got {e:?}"), + Ok(_) => panic!("expected InvalidPublicKey for u = {u:02x?}, got Ok"), } } } @@ -134,7 +135,7 @@ fn rfc7748_check_iterated(count: usize) { .unwrap(); u = k; - k = *r.as_bytes(); + k.copy_from_slice(r.as_ref()); iterations += 1; @@ -169,5 +170,5 @@ fn check_vector(sk: &str, su: &str, se: &str, text: &str) { let u = X25519PublicKey::from_bytes(&decode_32(su)).unwrap(); let r = X25519::key_agreement(&u, &k).unwrap(); - check_value(r.as_bytes(), text, se); + check_value(r.as_ref(), text, se); } diff --git a/crypto/ed/tests/x25519_wycheproof.rs b/crypto/ed/tests/x25519_wycheproof.rs index 1ffb016e1..e033c671d 100644 --- a/crypto/ed/tests/x25519_wycheproof.rs +++ b/crypto/ed/tests/x25519_wycheproof.rs @@ -45,7 +45,7 @@ fn wycheproof_x25519() { let (k, u) = (sk_from_bytes(&k), X25519PublicKey::from_bytes(&u).unwrap()); match X25519::key_agreement(&u, &k) { - Ok(r) => assert_eq!(r.as_bytes(), shared.as_slice(), "{ctx}: shared secret"), + Ok(r) => assert_eq!(r.as_ref(), shared.as_slice(), "{ctx}: shared secret"), Err(KeyAgreementError::InvalidPublicKey(_)) => { assert!(shared.iter().all(|&b| b == 0), "{ctx}: rejected a non-zero secret"); zero_shared += 1; diff --git a/mem_usage_benches/src/bench_x25519_mem_usage.rs b/mem_usage_benches/src/bench_x25519_mem_usage.rs index 737c2d419..69fe6a016 100644 --- a/mem_usage_benches/src/bench_x25519_mem_usage.rs +++ b/mem_usage_benches/src/bench_x25519_mem_usage.rs @@ -125,7 +125,7 @@ fn bench_x25519_key_agreement() { let pk = load_bob_pk(); measure(|| { let k = X25519::key_agreement(&pk, &sk).unwrap(); - print!("{:x?}", k.encode()); + print!("{:x?}", k.as_ref()); }); } From a2744b3d77d0f42b9c1d3d0c12cd592cf852d67a Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 30 Oct 2026 22:17:27 -0500 Subject: [PATCH 26/26] Removed the X25519PublicKey -> X25519DerivedKey promotion from the public API. --- crypto/ed/src/x25519.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/crypto/ed/src/x25519.rs b/crypto/ed/src/x25519.rs index 6e694bb3b..2715dcb58 100644 --- a/crypto/ed/src/x25519.rs +++ b/crypto/ed/src/x25519.rs @@ -375,7 +375,10 @@ impl KeyAgreement for pk: &X25519PublicKey, sk: &X25519PrivateKey, ) -> Result { - dh(sk, pk).map(X25519DerivedKey::from) + let k = dh(sk, pk)?; + let mut dk = Secret::<[u8; POINT_LEN]>::new(); + dk.copy_from_slice(k.as_bytes()); + Ok(X25519DerivedKey(dk)) } } @@ -573,16 +576,6 @@ impl AsRef<[u8]> for X25519DerivedKey { } } -/// Allow an X25519PublicKey to auto-promote to an X25519DerivedKey. -/// It's never a problem to treat a public value as secret. -impl From for X25519DerivedKey { - fn from(value: X25519PublicKey) -> Self { - let mut dk = Secret::<[u8; POINT_LEN]>::new(); - dk.copy_from_slice(value.as_bytes()); - Self(dk) - } -} - /// `decodeScalar25519(k)` as defined in Section 5 of RFC7748: /// /// ```text