Skip to main content

arcana/ecc/
x25519.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! X25519 Diffie-Hellman key agreement on Curve25519 (RFC 7748).
5//!
6//! X25519 is the "EDDH" (Edwards/Montgomery Diffie-Hellman) pairing
7//! around Curve25519 — a Montgomery curve over `p = 2^255 - 19`. It
8//! is the ECDH variant used by TLS 1.3, Noise, Signal, WireGuard,
9//! and most modern protocols that want a fixed, fast, audit-friendly
10//! ECDH.
11//!
12//! Unlike the short-Weierstrass curves exposed through the
13//! [`super::curves::Curve`] trait, X25519 works entirely on the
14//! **u-coordinate** (the x-coordinate of the Montgomery form). There
15//! is no point addition, no compressed/uncompressed distinction, and
16//! no branch on the y-coordinate — the Montgomery ladder operates on
17//! projective (X:Z) pairs and returns a single 32-byte little-endian
18//! u-coordinate.
19//!
20//! # Side-channel posture
21//!
22//! Per `arcana/doc/sca/countermeasures/x25519_x448.rst`:
23//!
24//! | Threat                                            | Status     | Roadmap item                                              |
25//! |---------------------------------------------------|------------|-----------------------------------------------------------|
26//! | Cache-timing on Montgomery ladder                 | partial    | `T1-G` — audit pass mirroring Weierstrass commit `76191c1`|
27//! | SPA on Cortex-M0 (Weissbart-Picek-Batina 2021)    | vulnerable | `T1-G` + `T2-A` (Z-rerand defeats their template attack)  |
28//! | DPA on field operations                           | vulnerable | `T2-A` — Z-rerandomization on `(X : Z)`                   |
29//! | Template attacks                                  | vulnerable | `T2-A` (alignment break) + `T2-B` (scalar blinding)       |
30//! | Invalid-curve attack on peer pubkey               | covered    | RFC 7748 twist security                                   |
31//! | Small-subgroup contributory check                 | partial    | `T2-K` — confirm CT all-zero rejection                    |
32//!
33//! Curve25519 is CT **by construction** (single u-coordinate, no
34//! special cases for the neutral element), but the concrete
35//! Rust implementation can still leak through the `cswap` mask
36//! pattern (see `super::field` `black_box` shielding) and
37//! through unmodelled cache-line accesses. The
38//! `weissbart2021_curve25519_ml_sca` paper demonstrated
39//! deep-learning template attacks on Cortex-M0 even against
40//! random-delay defences; Z-rerandomization is the standard
41//! answer.
42//!
43//! # API
44//!
45//! ```rust,ignore
46//! use arcana::ecc::x25519::{x25519_derive_public, x25519_ecdh};
47//!
48//! // Alice and Bob each draw 32 random bytes as their secret key.
49//! let alice_sk: [u8; 32] = /* rng */;
50//! let bob_sk:   [u8; 32] = /* rng */;
51//!
52//! // Derive public keys.
53//! let alice_pk = x25519_derive_public(&alice_sk);
54//! let bob_pk   = x25519_derive_public(&bob_sk);
55//!
56//! // Exchange public keys, then each derives the shared secret.
57//! let s_ab = x25519_ecdh(&alice_sk, &bob_pk);
58//! let s_ba = x25519_ecdh(&bob_sk,   &alice_pk);
59//! assert_eq!(s_ab, s_ba);
60//! ```
61//!
62//! # Test vectors
63//!
64//! The tests at the bottom of this file pin the two §5.2 primitive
65//! vectors and the §6.1 full Diffie-Hellman vector directly from
66//! RFC 7748. Any future regression in the ladder, the clamping, or
67//! the LE byte encoding fails against those bytes immediately.
68
69use super::field::*;
70
71// ============================================================================
72// Curve constants (RFC 7748 §4.1)
73// ============================================================================
74
75/// `a24 = (486662 - 2) / 4 = 121665` — the constant used inside the
76/// Montgomery ladder's doubling step.
77///
78/// Declared as a `FieldElement<4>` so it can be fed directly into
79/// `field_mul` without re-encoding on every call.
80fn a24() -> FieldElement<4> {
81    let mut fe = FieldElement::<4>::ZERO;
82    fe.limbs[0] = 121_665;
83    fe
84}
85
86/// X25519 base point u-coordinate = 9 (RFC 7748 §4.1).
87const BASE_U: [u8; 32] = {
88    let mut b = [0u8; 32];
89    b[0] = 9;
90    b
91};
92
93// ============================================================================
94// Scalar clamping + u-coordinate decoding (RFC 7748 §5)
95// ============================================================================
96
97/// RFC 7748 §5 `decodeScalar25519`: clamps the 32-byte secret scalar
98/// so that the resulting integer always lies in [2^254, 2^255) and
99/// is a multiple of 8. This is the property that lets the Montgomery
100/// ladder always iterate exactly 255 times with the top bit at
101/// position 254 guaranteed to be 1.
102///
103/// Concretely:
104/// - Clear the 3 low bits of byte 0 (= make the scalar a multiple of 8)
105/// - Clear the high bit of byte 31 (= force bit 255 = 0)
106/// - Set bit 6 of byte 31 (= force bit 254 = 1)
107fn decode_scalar(scalar: &[u8; 32]) -> [u8; 32] {
108    let mut k = *scalar;
109    k[0] &= 248; // 0b11111000
110    k[31] &= 127; // 0b01111111
111    k[31] |= 64; // 0b01000000
112    k
113}
114
115/// RFC 7748 §5 `decodeUCoordinate` (Curve25519 variant):
116/// clear the most significant bit of the last byte, then interpret
117/// as a little-endian integer in `Fp`.
118///
119/// Clearing the top bit matters because some peers (historically TLS)
120/// don't mask it themselves; the spec requires the receiver to be
121/// permissive.
122fn decode_u(u: &[u8; 32]) -> FieldElement<4> {
123    let mut buf = *u;
124    buf[31] &= 0x7f;
125    FieldElement::<4>::from_bytes_le(&buf)
126}
127
128/// Encode a field element as 32 little-endian bytes.
129fn encode_u(fe: &FieldElement<4>) -> [u8; 32] {
130    let v = fe.to_bytes_le();
131    let mut out = [0u8; 32];
132    out.copy_from_slice(&v);
133    out
134}
135
136// ============================================================================
137// Constant-time conditional swap of two field elements
138// ============================================================================
139
140/// Constant-time swap of `a` and `b` iff `swap == 1`.
141///
142/// `swap` must be 0 or 1. Uses a word-wide mask and XOR trick so the
143/// data dependency is the same for both branches (essential: the
144/// `swap` bit is derived from the secret scalar).
145fn ct_swap_fe(a: &mut FieldElement<4>, b: &mut FieldElement<4>, swap: u64) {
146    let mask = 0u64.wrapping_sub(swap);
147    for i in 0..4 {
148        let t = mask & (a.limbs[i] ^ b.limbs[i]);
149        a.limbs[i] ^= t;
150        b.limbs[i] ^= t;
151    }
152}
153
154// ============================================================================
155// The X25519 primitive — RFC 7748 §5, Montgomery ladder on (X:Z)
156// ============================================================================
157
158/// RFC 7748 §5 `X25519(scalar, u)`.
159///
160/// Takes a 32-byte little-endian scalar and a 32-byte little-endian
161/// u-coordinate, and returns the 32-byte little-endian u-coordinate
162/// of `scalar * (u, v)` on Curve25519 (where `v` is uniquely
163/// determined by `u` up to sign — we never need it).
164///
165/// The ladder operates on projective `(X, Z)` pairs where the affine
166/// u-coordinate is `X/Z`. 255 ladder steps are performed (bits 254..0
167/// of the clamped scalar); the high bit 254 is always 1 after
168/// clamping, so the first iteration deterministically initialises
169/// (x_2, x_3) = (u, 1), (z_2, z_3) = (1, u) via the cswap.
170pub fn x25519(scalar: &[u8; 32], u: &[u8; 32]) -> [u8; 32] {
171    let k = decode_scalar(scalar);
172    let x1 = decode_u(u);
173    let a24 = a24();
174    let p = &CURVE25519_P;
175
176    // Ladder state: x_2 = 1, z_2 = 0, x_3 = x1, z_3 = 1.
177    let mut x_2 = FieldElement::<4>::one();
178    let mut z_2 = FieldElement::<4>::ZERO;
179    let mut x_3 = x1;
180    let mut z_3 = FieldElement::<4>::one();
181
182    // `swap` tracks the conditional swap state between iterations.
183    // See RFC 7748 §5 for the reference Python pseudocode.
184    let mut swap: u64 = 0;
185
186    for t in (0..=254).rev() {
187        let k_t = ((k[t >> 3] >> (t & 7)) & 1) as u64;
188        swap ^= k_t;
189        ct_swap_fe(&mut x_2, &mut x_3, swap);
190        ct_swap_fe(&mut z_2, &mut z_3, swap);
191        swap = k_t;
192
193        // A  = x_2 + z_2
194        // AA = A^2
195        // B  = x_2 - z_2
196        // BB = B^2
197        // E  = AA - BB
198        // C  = x_3 + z_3
199        // D  = x_3 - z_3
200        // DA = D * A
201        // CB = C * B
202        // x_3 = (DA + CB)^2
203        // z_3 = x_1 * (DA - CB)^2
204        // x_2 = AA * BB
205        // z_2 = E * (AA + a24 * E)
206        let a = field_add(&x_2, &z_2, p);
207        let aa = field_sqr(&a, p);
208        let b = field_sub(&x_2, &z_2, p);
209        let bb = field_sqr(&b, p);
210        let e = field_sub(&aa, &bb, p);
211        let c = field_add(&x_3, &z_3, p);
212        let d = field_sub(&x_3, &z_3, p);
213        let da = field_mul(&d, &a, p);
214        let cb = field_mul(&c, &b, p);
215
216        let da_plus_cb = field_add(&da, &cb, p);
217        x_3 = field_sqr(&da_plus_cb, p);
218
219        let da_minus_cb = field_sub(&da, &cb, p);
220        let da_minus_cb_sq = field_sqr(&da_minus_cb, p);
221        z_3 = field_mul(&x1, &da_minus_cb_sq, p);
222
223        x_2 = field_mul(&aa, &bb, p);
224
225        let a24_e = field_mul(&a24, &e, p);
226        let aa_plus_a24e = field_add(&aa, &a24_e, p);
227        z_2 = field_mul(&e, &aa_plus_a24e, p);
228    }
229
230    // Final swap based on the residual `swap` state.
231    ct_swap_fe(&mut x_2, &mut x_3, swap);
232    ct_swap_fe(&mut z_2, &mut z_3, swap);
233
234    // Return x_2 / z_2 = x_2 * z_2^{p-2} mod p as 32 LE bytes.
235    let z_inv = field_inv(&z_2, p);
236    let result = field_mul(&x_2, &z_inv, p);
237    encode_u(&result)
238}
239
240// ============================================================================
241// Public API — keygen + ECDH convenience wrappers
242// ============================================================================
243
244/// Derive the X25519 **public key** from a 32-byte secret key.
245///
246/// The secret key is any 32 random bytes; the caller is responsible
247/// for drawing them from a suitable CSPRNG. The returned public key
248/// is the 32-byte u-coordinate of `sk * base` on Curve25519.
249///
250/// This is `X25519(sk, 9)` per RFC 7748 §5.
251pub fn x25519_derive_public(sk: &[u8; 32]) -> [u8; 32] {
252    x25519(sk, &BASE_U)
253}
254
255/// X25519 Diffie-Hellman: derive a shared secret from our secret key
256/// and the peer's public key.
257///
258/// Returns the 32-byte u-coordinate of `sk * peer_pk`. This is the
259/// raw shared secret (NIST SP 800-56A "Z"); pass it to an HKDF or
260/// similar KDF before using it for symmetric keying.
261///
262/// # Small-subgroup attack note
263///
264/// RFC 7748 §6.1 warns that X25519 accepts certain "contributory"
265/// public keys (points in small subgroups) that collapse the shared
266/// secret to a fixed value. A defensive implementation may want to
267/// reject the result if it is all-zero, which signals the peer sent
268/// a low-order point. We deliberately do **not** reject here because
269/// (a) the spec allows it, and (b) a downstream KDF with context
270/// binding (TLS 1.3 `transcript_hash`, Noise `HKDF`, ...) is the
271/// standard mitigation. Callers who want the low-order check can
272/// run it themselves:
273///
274/// ```rust,ignore
275/// let shared = x25519_ecdh(&sk, &peer_pk);
276/// if shared.iter().all(|&b| b == 0) {
277///     return Err("contributory shared secret");
278/// }
279/// ```
280pub fn x25519_ecdh(sk: &[u8; 32], peer_pk: &[u8; 32]) -> [u8; 32] {
281    x25519(sk, peer_pk)
282}
283
284// ============================================================================
285// Tests (RFC 7748 pinned vectors)
286// ============================================================================
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn hex32(h: &str) -> [u8; 32] {
293        assert_eq!(h.len(), 64);
294        let mut out = [0u8; 32];
295        for i in 0..32 {
296            out[i] = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).unwrap();
297        }
298        out
299    }
300
301    // ----- RFC 7748 §5.2 primitive test vector #1 -----
302    //
303    // Scalar:      a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4
304    // u:           e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c
305    // X25519(k,u): c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552
306    #[test]
307    fn rfc7748_section_5_2_vector_1() {
308        let scalar = hex32("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4");
309        let u = hex32("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c");
310        let expected = hex32("c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552");
311        let got = x25519(&scalar, &u);
312        assert_eq!(got, expected);
313    }
314
315    // ----- RFC 7748 §5.2 primitive test vector #2 -----
316    //
317    // Scalar:      4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d
318    // u:           e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493
319    // X25519(k,u): 95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957
320    #[test]
321    fn rfc7748_section_5_2_vector_2() {
322        let scalar = hex32("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d");
323        let u = hex32("e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493");
324        let expected = hex32("95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957");
325        let got = x25519(&scalar, &u);
326        assert_eq!(got, expected);
327    }
328
329    // ----- RFC 7748 §6.1 full Diffie-Hellman test vector -----
330    //
331    // Alice's private key: 77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a
332    // Alice's public key:  8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a
333    // Bob's private key:   5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb
334    // Bob's public key:    de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f
335    // Shared secret (K):   4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742
336    #[test]
337    fn rfc7748_section_6_1_alice_pk() {
338        let alice_sk = hex32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
339        let expected = hex32("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
340        assert_eq!(x25519_derive_public(&alice_sk), expected);
341    }
342
343    #[test]
344    fn rfc7748_section_6_1_bob_pk() {
345        let bob_sk = hex32("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
346        let expected = hex32("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
347        assert_eq!(x25519_derive_public(&bob_sk), expected);
348    }
349
350    #[test]
351    fn rfc7748_section_6_1_shared_secret() {
352        let alice_sk = hex32("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
353        let bob_sk = hex32("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
354        let alice_pk = x25519_derive_public(&alice_sk);
355        let bob_pk = x25519_derive_public(&bob_sk);
356        let expected = hex32("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
357        assert_eq!(x25519_ecdh(&alice_sk, &bob_pk), expected);
358        assert_eq!(x25519_ecdh(&bob_sk, &alice_pk), expected);
359    }
360
361    // ----- Roundtrip on an arbitrary key pair -----
362    #[test]
363    fn x25519_roundtrip_custom_keys() {
364        let alice_sk = hex32("0101010101010101010101010101010101010101010101010101010101010101");
365        let bob_sk = hex32("0202020202020202020202020202020202020202020202020202020202020202");
366        let alice_pk = x25519_derive_public(&alice_sk);
367        let bob_pk = x25519_derive_public(&bob_sk);
368        let s_ab = x25519_ecdh(&alice_sk, &bob_pk);
369        let s_ba = x25519_ecdh(&bob_sk, &alice_pk);
370        assert_eq!(s_ab, s_ba);
371        // Sanity: the shared secret is non-zero (an all-zero output
372        // would signal a low-order input point; our TestRng keys are
373        // not in a small subgroup).
374        assert!(s_ab.iter().any(|&b| b != 0));
375    }
376
377    // ----- Clamping test -----
378    //
379    // Changing any of the bits that `decode_scalar` forces (low 3
380    // bits of byte 0, top 2 bits of byte 31) must not change the
381    // output. This exercises the clamping path in isolation.
382    #[test]
383    fn clamping_is_idempotent() {
384        let base = hex32("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4");
385        let u = hex32("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c");
386        let ref_out = x25519(&base, &u);
387
388        // Force-dirty the bits that clamping will clear anyway.
389        let mut dirty = base;
390        dirty[0] |= 0b0000_0111; // set low 3 bits (will be cleared)
391        dirty[31] |= 0b1000_0000; // set high bit (will be cleared)
392        dirty[31] &= !0b0100_0000; // clear bit 6 (will be set)
393        let dirty_out = x25519(&dirty, &u);
394        assert_eq!(ref_out, dirty_out);
395    }
396
397    // ----- High-bit-of-u decoding test -----
398    //
399    // decode_u clears the top bit of byte 31 per RFC 7748, so toggling
400    // that bit on the input u must not change the output.
401    #[test]
402    fn u_high_bit_is_ignored() {
403        let scalar = hex32("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4");
404        let u = hex32("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c");
405        let ref_out = x25519(&scalar, &u);
406
407        let mut u_dirty = u;
408        u_dirty[31] |= 0x80;
409        let dirty_out = x25519(&scalar, &u_dirty);
410        assert_eq!(ref_out, dirty_out);
411    }
412}