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}