Skip to main content

arcana/ecc/
x448.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! X448 Diffie-Hellman key agreement on Curve448 (RFC 7748).
5//!
6//! X448 is the 448-bit sibling of [`super::x25519`]: same Montgomery-
7//! ladder structure, different field prime (`p = 2^448 - 2^224 - 1`),
8//! different base-point u-coordinate (`5` instead of `9`), different
9//! scalar clamping, and 56-byte little-endian encoding throughout
10//! (not 32). It is the ECDH half of the RFC 8032 "Ed448 / X448" pair
11//! and the higher-security tier of the Curve25519 / Curve448 family
12//! used in TLS 1.3, Noise, and Signal's future-proofing profiles.
13//!
14//! # Side-channel posture
15//!
16//! Same plan as `super::x25519`
17//! (see `arcana/doc/sca/countermeasures/x25519_x448.rst`):
18//! `T1-G` audit pass, `T2-A` Z-rerandomization on `(X : Z)`, `T2-B`
19//! scalar blinding. The implementation route is identical mutatis
20//! mutandis on Curve448; the field arithmetic shares
21//! [`super::field`]'s `black_box`-shielded mask selects.
22//!
23//! # Constants (RFC 7748 §4.2)
24//!
25//! | Parameter      | Value                                          |
26//! |----------------|------------------------------------------------|
27//! | p              | `2^448 - 2^224 - 1`                            |
28//! | A              | `156326`                                       |
29//! | a24            | `(A - 2) / 4 = 39081`                          |
30//! | Base u         | `5`                                            |
31//! | Scalar bits    | 448 (clamping sets bit 447, clears low 2 bits) |
32//!
33//! # API
34//!
35//! Mirror of the X25519 API with 56-byte arrays:
36//!
37//! ```rust,ignore
38//! use arcana::ecc::x448::{x448_derive_public, x448_ecdh};
39//!
40//! let alice_sk: [u8; 56] = /* rng */;
41//! let bob_sk:   [u8; 56] = /* rng */;
42//!
43//! let alice_pk = x448_derive_public(&alice_sk);
44//! let bob_pk   = x448_derive_public(&bob_sk);
45//!
46//! let s_ab = x448_ecdh(&alice_sk, &bob_pk);
47//! let s_ba = x448_ecdh(&bob_sk,   &alice_pk);
48//! assert_eq!(s_ab, s_ba);
49//! ```
50//!
51//! # Test vectors
52//!
53//! The tests at the bottom of this file pin the two primitive vectors
54//! from RFC 7748 §5.2 and the full Diffie-Hellman vector from §6.2
55//! byte-exact. Together they exercise ladder, clamping, and LE
56//! encoding against the spec.
57
58use super::field::*;
59
60// ============================================================================
61// Curve constants (RFC 7748 §4.2)
62// ============================================================================
63
64/// `a24 = (156326 - 2) / 4 = 39081`.
65fn a24() -> FieldElement<7> {
66    let mut fe = FieldElement::<7>::ZERO;
67    fe.limbs[0] = 39_081;
68    fe
69}
70
71/// Curve448 base point u-coordinate = 5 (RFC 7748 §4.2).
72const BASE_U: [u8; 56] = {
73    let mut b = [0u8; 56];
74    b[0] = 5;
75    b
76};
77
78// ============================================================================
79// Scalar clamping + u-coordinate decoding (RFC 7748 §5)
80// ============================================================================
81
82/// RFC 7748 §5 `decodeScalar448`: force the scalar into the range
83/// `[2^447, 2^448)` and a multiple of 4.
84///
85/// - Clear the 2 low bits of byte 0 (multiple of 4)
86/// - Set the top bit of byte 55 (bit 447 of the 448-bit LE integer)
87///
88/// Note: unlike X25519 there is no "clear bit 255" step because 448
89/// is already byte-aligned.
90fn decode_scalar(scalar: &[u8; 56]) -> [u8; 56] {
91    let mut k = *scalar;
92    k[0] &= 252; // 0b11111100
93    k[55] |= 128; // 0b10000000
94    k
95}
96
97/// RFC 7748 §5 `decodeUCoordinate` for Curve448.
98///
99/// Unlike Curve25519, Curve448's u is decoded as a plain little-endian
100/// integer in `Fp` with no high-bit masking: 448 is already a multiple
101/// of 8, so the RFC's "clear the high bits above (qlen mod 8)" step
102/// is a no-op.
103fn decode_u(u: &[u8; 56]) -> FieldElement<7> {
104    FieldElement::<7>::from_bytes_le(u)
105}
106
107/// Encode a field element as 56 little-endian bytes.
108fn encode_u(fe: &FieldElement<7>) -> [u8; 56] {
109    let v = fe.to_bytes_le();
110    let mut out = [0u8; 56];
111    out.copy_from_slice(&v);
112    out
113}
114
115// ============================================================================
116// Constant-time conditional swap of two field elements
117// ============================================================================
118
119/// Constant-time swap of `a` and `b` iff `swap == 1`.
120///
121/// Same structure as the X25519 helper but sized for LIMBS=7 instead
122/// of 4. `swap` must be 0 or 1.
123fn ct_swap_fe(a: &mut FieldElement<7>, b: &mut FieldElement<7>, swap: u64) {
124    let mask = 0u64.wrapping_sub(swap);
125    for i in 0..7 {
126        let t = mask & (a.limbs[i] ^ b.limbs[i]);
127        a.limbs[i] ^= t;
128        b.limbs[i] ^= t;
129    }
130}
131
132// ============================================================================
133// The X448 primitive — RFC 7748 §5, Montgomery ladder on (X:Z)
134// ============================================================================
135
136/// RFC 7748 §5 `X448(scalar, u)`.
137///
138/// Takes a 56-byte little-endian scalar and a 56-byte little-endian
139/// u-coordinate, and returns the 56-byte little-endian u-coordinate
140/// of `scalar * (u, v)` on Curve448.
141///
142/// The ladder runs 448 iterations (bits 447..0 of the clamped
143/// scalar); the clamp sets bit 447 unconditionally, so the first
144/// cswap initializes (x_2, x_3) = (u, 1), (z_2, z_3) = (1, u).
145pub fn x448(scalar: &[u8; 56], u: &[u8; 56]) -> [u8; 56] {
146    let k = decode_scalar(scalar);
147    let x1 = decode_u(u);
148    let a24 = a24();
149    let p = &CURVE448_P;
150
151    // Ladder state: x_2 = 1, z_2 = 0, x_3 = x1, z_3 = 1.
152    let mut x_2 = FieldElement::<7>::one();
153    let mut z_2 = FieldElement::<7>::ZERO;
154    let mut x_3 = x1;
155    let mut z_3 = FieldElement::<7>::one();
156
157    let mut swap: u64 = 0;
158
159    for t in (0..=447).rev() {
160        let k_t = ((k[t >> 3] >> (t & 7)) & 1) as u64;
161        swap ^= k_t;
162        ct_swap_fe(&mut x_2, &mut x_3, swap);
163        ct_swap_fe(&mut z_2, &mut z_3, swap);
164        swap = k_t;
165
166        // RFC 7748 §5 ladder step (same for Curve25519 and Curve448,
167        // only a24 and the field prime differ):
168        //
169        //   A   = x_2 + z_2
170        //   AA  = A^2
171        //   B   = x_2 - z_2
172        //   BB  = B^2
173        //   E   = AA - BB
174        //   C   = x_3 + z_3
175        //   D   = x_3 - z_3
176        //   DA  = D * A
177        //   CB  = C * B
178        //   x_3 = (DA + CB)^2
179        //   z_3 = x_1 * (DA - CB)^2
180        //   x_2 = AA * BB
181        //   z_2 = E * (AA + a24 * E)
182        let a = field_add(&x_2, &z_2, p);
183        let aa = field_sqr(&a, p);
184        let b = field_sub(&x_2, &z_2, p);
185        let bb = field_sqr(&b, p);
186        let e = field_sub(&aa, &bb, p);
187        let c = field_add(&x_3, &z_3, p);
188        let d = field_sub(&x_3, &z_3, p);
189        let da = field_mul(&d, &a, p);
190        let cb = field_mul(&c, &b, p);
191
192        let da_plus_cb = field_add(&da, &cb, p);
193        x_3 = field_sqr(&da_plus_cb, p);
194
195        let da_minus_cb = field_sub(&da, &cb, p);
196        let da_minus_cb_sq = field_sqr(&da_minus_cb, p);
197        z_3 = field_mul(&x1, &da_minus_cb_sq, p);
198
199        x_2 = field_mul(&aa, &bb, p);
200
201        let a24_e = field_mul(&a24, &e, p);
202        let aa_plus_a24e = field_add(&aa, &a24_e, p);
203        z_2 = field_mul(&e, &aa_plus_a24e, p);
204    }
205
206    // Final unmasking swap.
207    ct_swap_fe(&mut x_2, &mut x_3, swap);
208    ct_swap_fe(&mut z_2, &mut z_3, swap);
209
210    // Return x_2 / z_2 = x_2 * z_2^{p-2} mod p as 56 LE bytes.
211    let z_inv = field_inv(&z_2, p);
212    let result = field_mul(&x_2, &z_inv, p);
213    encode_u(&result)
214}
215
216// ============================================================================
217// Public API — keygen + ECDH convenience wrappers
218// ============================================================================
219
220/// Derive the X448 public key from a 56-byte secret key.
221///
222/// Equivalent to `x448(sk, 5)` per RFC 7748 §5.
223pub fn x448_derive_public(sk: &[u8; 56]) -> [u8; 56] {
224    x448(sk, &BASE_U)
225}
226
227/// X448 Diffie-Hellman: derive a shared secret from our secret key
228/// and the peer's public key.
229///
230/// Returns the 56-byte u-coordinate of `sk * peer_pk`. As with X25519,
231/// this is the raw SP 800-56A "Z" value; callers feed it into a KDF
232/// of their choice before using it for symmetric keying. See the
233/// analogous doc in [`super::x25519`] for the low-order defensive
234/// check rationale.
235pub fn x448_ecdh(sk: &[u8; 56], peer_pk: &[u8; 56]) -> [u8; 56] {
236    x448(sk, peer_pk)
237}
238
239// ============================================================================
240// Tests (RFC 7748 pinned vectors)
241// ============================================================================
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn hex56(h: &str) -> [u8; 56] {
248        assert_eq!(h.len(), 112);
249        let mut out = [0u8; 56];
250        for i in 0..56 {
251            out[i] = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).unwrap();
252        }
253        out
254    }
255
256    // ----- RFC 7748 §5.2 primitive test vector #1 -----
257    //
258    // Scalar:
259    //   3d262fddf9ec8e88495266fea19a34d28882acef045104d0d1aae12170
260    //   0a779c984c24f8cdd78fbff44943eba368f54b29259a4f1c600ad3
261    // u-coordinate:
262    //   06fce640fa3487bfda5f6cf2d5263f8aad88334cbd07437f020f08f981
263    //   4dc031ddbdc38c19c6da2583fa5429db94ada18aa7a7fb4ef8a086
264    // X448(k, u):
265    //   ce3e4ff95a60dc6697da1db1d85e6afbdf79b50a2412d7546d5f239fe1
266    //   4fbaadeb445fc66a01b0779d98223961111e21766282f73dd96b6f
267    #[test]
268    fn rfc7748_section_5_2_vector_1() {
269        let scalar = hex56(
270            "3d262fddf9ec8e88495266fea19a34d28882acef045104d0d1aae121700a779c984c24f8cdd78fbff44943eba368f54b29259a4f1c600ad3",
271        );
272        let u = hex56(
273            "06fce640fa3487bfda5f6cf2d5263f8aad88334cbd07437f020f08f9814dc031ddbdc38c19c6da2583fa5429db94ada18aa7a7fb4ef8a086",
274        );
275        let expected = hex56(
276            "ce3e4ff95a60dc6697da1db1d85e6afbdf79b50a2412d7546d5f239fe14fbaadeb445fc66a01b0779d98223961111e21766282f73dd96b6f",
277        );
278        assert_eq!(x448(&scalar, &u), expected);
279    }
280
281    // ----- RFC 7748 §5.2 primitive test vector #2 -----
282    #[test]
283    fn rfc7748_section_5_2_vector_2() {
284        let scalar = hex56(
285            "203d494428b8399352665ddca42f9de8fef600908e0d461cb021f8c538345dd77c3e4806e25f46d3315c44e0a5b4371282dd2c8d5be3095f",
286        );
287        let u = hex56(
288            "0fbcc2f993cd56d3305b0b7d9e55d4c1a8fb5dbb52f8e9a1e9b6201b165d015894e56c4d3570bee52fe205e28a78b91cdfbde71ce8d157db",
289        );
290        let expected = hex56(
291            "884a02576239ff7a2f2f63b2db6a9ff37047ac13568e1e30fe63c4a7ad1b3ee3a5700df34321d62077e63633c575c1c954514e99da7c179d",
292        );
293        assert_eq!(x448(&scalar, &u), expected);
294    }
295
296    // ----- RFC 7748 §6.2 full Diffie-Hellman test vector -----
297    //
298    // Alice's private key: 9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28d
299    //                      d9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b
300    // Alice's public key:  9b08f7cc31b7e3e67d22d5aea121074a273bd2b83de09c63faa73d2c
301    //                      22c5d9bbc836647241d953d40c5b12da88120d53177f80e532c41fa0
302    // Bob's private key:   1c306a7ac2a0e2e0990b294470cba339e6453772b075811d8fad0d1d
303    //                      6927c120bb5ee8972b0d3e21374c9c921b09d1b0366f10b65173992d
304    // Bob's public key:    3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b430
305    //                      27d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609
306    // Shared secret K:     07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282b
307    //                      b60c0b56fd2464c335543936521c24403085d59a449a5037514a879d
308    #[test]
309    fn rfc7748_section_6_2_alice_pk() {
310        let alice_sk = hex56(
311            "9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b",
312        );
313        let expected = hex56(
314            "9b08f7cc31b7e3e67d22d5aea121074a273bd2b83de09c63faa73d2c22c5d9bbc836647241d953d40c5b12da88120d53177f80e532c41fa0",
315        );
316        assert_eq!(x448_derive_public(&alice_sk), expected);
317    }
318
319    #[test]
320    fn rfc7748_section_6_2_bob_pk() {
321        let bob_sk = hex56(
322            "1c306a7ac2a0e2e0990b294470cba339e6453772b075811d8fad0d1d6927c120bb5ee8972b0d3e21374c9c921b09d1b0366f10b65173992d",
323        );
324        let expected = hex56(
325            "3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b43027d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609",
326        );
327        assert_eq!(x448_derive_public(&bob_sk), expected);
328    }
329
330    #[test]
331    fn rfc7748_section_6_2_shared_secret() {
332        let alice_sk = hex56(
333            "9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b",
334        );
335        let bob_sk = hex56(
336            "1c306a7ac2a0e2e0990b294470cba339e6453772b075811d8fad0d1d6927c120bb5ee8972b0d3e21374c9c921b09d1b0366f10b65173992d",
337        );
338        let alice_pk = x448_derive_public(&alice_sk);
339        let bob_pk = x448_derive_public(&bob_sk);
340        let expected = hex56(
341            "07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d",
342        );
343        assert_eq!(x448_ecdh(&alice_sk, &bob_pk), expected);
344        assert_eq!(x448_ecdh(&bob_sk, &alice_pk), expected);
345    }
346
347    // ----- Round-trip on arbitrary keys -----
348    #[test]
349    fn x448_roundtrip_custom_keys() {
350        let alice_sk = hex56(
351            "0101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101",
352        );
353        let bob_sk = hex56(
354            "0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
355        );
356        let alice_pk = x448_derive_public(&alice_sk);
357        let bob_pk = x448_derive_public(&bob_sk);
358        let s_ab = x448_ecdh(&alice_sk, &bob_pk);
359        let s_ba = x448_ecdh(&bob_sk, &alice_pk);
360        assert_eq!(s_ab, s_ba);
361        assert!(s_ab.iter().any(|&b| b != 0));
362    }
363
364    // ----- Clamping idempotence -----
365    //
366    // Forcing the low 2 bits of byte 0 and the top bit of byte 55 to
367    // "dirty" values must not change the output, because `decode_scalar`
368    // overrides them unconditionally.
369    #[test]
370    fn clamping_is_idempotent() {
371        let base = hex56(
372            "3d262fddf9ec8e88495266fea19a34d28882acef045104d0d1aae121700a779c984c24f8cdd78fbff44943eba368f54b29259a4f1c600ad3",
373        );
374        let u = hex56(
375            "06fce640fa3487bfda5f6cf2d5263f8aad88334cbd07437f020f08f9814dc031ddbdc38c19c6da2583fa5429db94ada18aa7a7fb4ef8a086",
376        );
377        let ref_out = x448(&base, &u);
378
379        let mut dirty = base;
380        dirty[0] |= 0b0000_0011; // low 2 bits (cleared by clamp)
381        dirty[55] &= !0b1000_0000; // top bit (set by clamp)
382        let dirty_out = x448(&dirty, &u);
383        assert_eq!(ref_out, dirty_out);
384    }
385}