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