Skip to main content

arcana/ecc/
ecdh.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! Elliptic Curve Diffie-Hellman (ECDH) on short Weierstrass curves.
5//!
6//! ECDH is exposed as a method on the [`Curve`](super::curves::Curve) trait,
7//! alongside ECDSA sign / verify and keygen. The same seven unit structs cover
8//! all curve operations:
9//!
10//! ```ignore
11//! use arcana::ecc::curves::{Curve, P256};
12//!
13//! let mut rng = OsRng;
14//! let (alice_pk, alice_sk) = P256::keygen(&mut rng);
15//! let (bob_pk,   bob_sk)   = P256::keygen(&mut rng);
16//!
17//! let secret_a = P256::ecdh(&alice_sk, &bob_pk).expect("alice ecdh");
18//! let secret_b = P256::ecdh(&bob_sk,   &alice_pk).expect("bob ecdh");
19//! assert_eq!(secret_a, secret_b);
20//! ```
21//!
22//! # Supported curves
23//!
24//! All seven [`Curve`](super::curves::Curve) implementors:
25//! [`P256`](super::curves::P256), [`P384`](super::curves::P384),
26//! [`P521`](super::curves::P521),
27//! [`Secp256k1`](super::curves::Secp256k1),
28//! [`BrainpoolP256r1`](super::curves::BrainpoolP256r1),
29//! [`BrainpoolP384r1`](super::curves::BrainpoolP384r1),
30//! [`BrainpoolP512r1`](super::curves::BrainpoolP512r1).
31//!
32//! # Output format
33//!
34//! [`Curve::ecdh`](super::curves::Curve::ecdh) returns the **raw X coordinate**
35//! of the shared point, encoded as `LIMBS * 8` big-endian bytes. This matches
36//! NIST SP 800-56A §5.7.1.2 ("ECC CDH Primitive") and the TLS / IKE
37//! conventions. Higher-level KDFs (HKDF, X9.63 KDF, ...) are out of scope
38//! for this layer.
39//!
40//! # Public key validation (mandatory)
41//!
42//! Before multiplying the secret scalar by a peer's public key, `ecdh`
43//! validates that the peer's point is actually on the curve. **Skipping
44//! this check enables invalid-curve attacks** that recover bits of the
45//! secret key one chosen point at a time, so it is non-negotiable here.
46//! See [`super::curve::is_on_curve`]. The same internal entry point
47//! is used by [`Curve::verify`](super::curves::Curve::verify), so the
48//! validation rules cannot drift between ECDH and ECDSA verify.
49//!
50//! Validation performed on every `ecdh` call:
51//! 1. SEC1 uncompressed format: `pk.bytes.len() == 1 + 2*LIMBS*8`
52//! 2. Format byte: `pk.bytes[0] == 0x04`
53//! 3. Coordinates are field elements in `[0, p)` (implicit via decoding)
54//! 4. Point satisfies `y² = x³ + a·x + b mod p`
55//! 5. Resulting shared point is not the point at infinity (small-subgroup
56//!    defence in depth)
57//!
58//! # Test-only file
59//!
60//! The actual implementation lives next to the other LIMBS-generic curve
61//! helpers in [`super::ecdsa`] (`ecdh_internal<LIMBS>`) and is dispatched
62//! through the [`Curve`](super::curves::Curve) trait in [`super::curves`].
63//! This file exists to host the ECDH-specific documentation and the
64//! integration tests for ECDH; there is no public API defined here.
65
66// ============================================================================
67// Tests
68// ============================================================================
69
70#[cfg(test)]
71mod tests {
72    use super::super::curves::{
73        BrainpoolP256r1, BrainpoolP384r1, BrainpoolP512r1, CryptoRng, Curve, P256, P384, PublicKey, Secp256k1,
74        SecretKey,
75    };
76
77    /// Tiny deterministic xorshift64 RNG, for tests only. NOT cryptographic.
78    struct TestRng {
79        state: u64,
80    }
81
82    impl TestRng {
83        fn new(seed: u64) -> Self {
84            Self {
85                state: if seed == 0 { 0xdeadbeefcafef00d } else { seed },
86            }
87        }
88    }
89
90    impl CryptoRng for TestRng {
91        fn fill_bytes(&mut self, dest: &mut [u8]) {
92            for chunk in dest.chunks_mut(8) {
93                let mut x = self.state;
94                x ^= x << 13;
95                x ^= x >> 7;
96                x ^= x << 17;
97                self.state = x;
98                for (i, b) in chunk.iter_mut().enumerate() {
99                    *b = (x >> (8 * i)) as u8;
100                }
101            }
102        }
103    }
104
105    /// Generic round-trip on any curve: generate Alice and Bob keypairs,
106    /// each derives the shared secret, the two outputs must match exactly.
107    ///
108    /// Generic over `C: Curve` so the same body is reused for all six
109    /// curves. Tests just call `roundtrip::<P256>()` etc.
110    fn roundtrip<C: Curve>() {
111        let mut rng_a = TestRng::new(0xA11CE);
112        let mut rng_b = TestRng::new(0xB0B);
113        let (pk_a, sk_a) = C::keygen(&mut rng_a);
114        let (pk_b, sk_b) = C::keygen(&mut rng_b);
115
116        let s_ab = C::ecdh(&sk_a, &pk_b).expect("alice ecdh ok");
117        let s_ba = C::ecdh(&sk_b, &pk_a).expect("bob ecdh ok");
118
119        assert_eq!(s_ab, s_ba, "ECDH round-trip mismatch");
120        // Sanity: shared secret is non-zero (the chance of a true zero is
121        // negligible, so a zero output here means a bug).
122        assert!(s_ab.iter().any(|&b| b != 0), "shared secret is all-zero");
123    }
124
125    #[test]
126    fn ecdh_p256_roundtrip() {
127        roundtrip::<P256>();
128    }
129
130    #[test]
131    fn ecdh_p384_roundtrip() {
132        roundtrip::<P384>();
133    }
134
135    #[test]
136    fn ecdh_secp256k1_roundtrip() {
137        roundtrip::<Secp256k1>();
138    }
139
140    #[test]
141    fn ecdh_brainpoolp256r1_roundtrip() {
142        roundtrip::<BrainpoolP256r1>();
143    }
144
145    #[test]
146    fn ecdh_brainpoolp384r1_roundtrip() {
147        roundtrip::<BrainpoolP384r1>();
148    }
149
150    #[test]
151    fn ecdh_brainpoolp512r1_roundtrip() {
152        roundtrip::<BrainpoolP512r1>();
153    }
154
155    /// The shared secret has the expected width (LIMBS*8 BE bytes).
156    #[test]
157    fn ecdh_shared_secret_widths() {
158        let mut rng = TestRng::new(1);
159        let (pk_a, _sk_a) = P256::keygen(&mut rng);
160        let (_pk_b, sk_b) = P256::keygen(&mut rng);
161        let s = P256::ecdh(&sk_b, &pk_a).unwrap();
162        assert_eq!(s.len(), 32);
163
164        let (pk_a, _sk_a) = P384::keygen(&mut rng);
165        let (_pk_b, sk_b) = P384::keygen(&mut rng);
166        let s = P384::ecdh(&sk_b, &pk_a).unwrap();
167        assert_eq!(s.len(), 48);
168
169        let (pk_a, _sk_a) = BrainpoolP512r1::keygen(&mut rng);
170        let (_pk_b, sk_b) = BrainpoolP512r1::keygen(&mut rng);
171        let s = BrainpoolP512r1::ecdh(&sk_b, &pk_a).unwrap();
172        assert_eq!(s.len(), 64);
173    }
174
175    /// ECDH and ECDSA share key pairs: a key produced by `P256::keygen`
176    /// works as input to both `P256::ecdh` and `P256::sign_rfc6979` /
177    /// `P256::verify`. This test locks in that interchangeability so a
178    /// future change cannot accidentally split the key types.
179    #[test]
180    fn ecdh_and_ecdsa_share_keys() {
181        use crate::hash::sha256::Sha256;
182
183        let mut rng = TestRng::new(2);
184        let (alice_pk, alice_sk) = P256::keygen(&mut rng);
185        let (bob_pk, bob_sk) = P256::keygen(&mut rng);
186
187        // ECDH on the shared key pair.
188        let shared_a = P256::ecdh(&alice_sk, &bob_pk).expect("alice ecdh");
189        let shared_b = P256::ecdh(&bob_sk, &alice_pk).expect("bob ecdh");
190        assert_eq!(shared_a, shared_b);
191
192        // Sign with the *same* sk that just did ECDH, verify with the *same*
193        // pk. If the key types had drifted between ECDH and ECDSA, one of
194        // the calls below would not type-check.
195        let msg = b"keys are the same";
196        let sig = P256::sign_rfc6979_msg::<Sha256>(&alice_sk, msg);
197        assert!(P256::verify_msg::<Sha256>(&alice_pk, msg, &sig));
198    }
199
200    /// `ecdh` must reject a malformed (wrong length) public key.
201    #[test]
202    fn ecdh_rejects_wrong_length_pubkey() {
203        let mut rng = TestRng::new(7);
204        let (_pk, sk) = P256::keygen(&mut rng);
205        let bad = PublicKey { bytes: vec![0x04; 64] }; // 64 instead of 65
206        assert!(P256::ecdh(&sk, &bad).is_none());
207    }
208
209    /// `ecdh` must reject a public key with the wrong tag byte.
210    #[test]
211    fn ecdh_rejects_wrong_tag_pubkey() {
212        let mut rng = TestRng::new(8);
213        let (mut pk, sk) = P256::keygen(&mut rng);
214        pk.bytes[0] = 0x02; // compressed-format tag, we only support 0x04
215        assert!(P256::ecdh(&sk, &pk).is_none());
216    }
217
218    /// `ecdh` must reject an off-curve public key (the invalid-curve attack
219    /// defence). We construct one by flipping a single byte of Y.
220    #[test]
221    fn ecdh_rejects_off_curve_pubkey() {
222        let mut rng = TestRng::new(9);
223        let (mut pk, sk) = P256::keygen(&mut rng);
224        let len = pk.bytes.len();
225        pk.bytes[len - 1] ^= 0x01;
226        assert!(
227            P256::ecdh(&sk, &pk).is_none(),
228            "off-curve point must be rejected by ECDH"
229        );
230    }
231
232    /// `ecdh` must reject the encoded point at infinity (encoded as
233    /// `0x04 || 0...0 || 0...0`). It is off-curve since `0 != 0 + 0 + b`
234    /// for non-zero `b`.
235    #[test]
236    fn ecdh_rejects_infinity_pubkey() {
237        let mut rng = TestRng::new(10);
238        let (_pk, sk) = P256::keygen(&mut rng);
239        let mut bytes = vec![0u8; 65];
240        bytes[0] = 0x04;
241        let pk = PublicKey { bytes };
242        assert!(P256::ecdh(&sk, &pk).is_none());
243    }
244
245    /// `ecdh` must reject a syntactically valid PK whose secret scalar is
246    /// invalid (e.g. our SecretKey is all zeros, which encodes d=0).
247    #[test]
248    fn ecdh_rejects_zero_secret() {
249        let mut rng = TestRng::new(11);
250        let (pk, _sk_real) = P256::keygen(&mut rng);
251        let sk_zero = SecretKey { bytes: vec![0u8; 32] };
252        assert!(P256::ecdh(&sk_zero, &pk).is_none());
253    }
254}