Skip to main content

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}