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}