Skip to main content

zerodds_security_keyexchange/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Crate `zerodds-security-keyexchange`. Safety classification: **SAFE** (duenner Wrapper um `ring::agreement` + `ring::hkdf`).
5//!
6//! X25519 / P-256 Ephemeral-Diffie-Hellman fuer den
7//! DDS-Security 1.1 Authentication-Handshake (Spec §8.3.2).
8//!
9//! ## Schichten-Position
10//!
11//! Layer 4 — Core Services. Konsumiert von `zerodds-security-pki`.
12//!
13//! ## Zweck
14//!
15//! Der `Authentication`-Handshake (Spec §8.3.2) braucht am Ende einen
16//! `SharedSecret`. Der uebliche Weg ist ephemeral-DH: jede Seite
17//! erzeugt ein temporaeres Schluesselpaar, tauscht die Public-Keys
18//! aus, und leitet das Shared-Secret aus `x25519(priv, remote_pub)`
19//! ab. HKDF-SHA256 zieht daraus einen 32-byte Key.
20//!
21//! # API
22//!
23//! ```
24//! use zerodds_security_keyexchange::KeyExchange;
25//!
26//! // Beide Seiten erzeugen ephemerals.
27//! let alice = KeyExchange::new().expect("alice");
28//! let bob = KeyExchange::new().expect("bob");
29//!
30//! // Public-Keys tauschen (ueber den SPDP-Handshake-Token).
31//! let a_pub = alice.public_key().to_vec();
32//! let b_pub = bob.public_key().to_vec();
33//!
34//! // Jede Seite leitet den gleichen 32-byte SharedSecret ab.
35//! let s1 = alice.derive_shared_secret(&b_pub).expect("alice derive");
36//! let s2 = bob.derive_shared_secret(&a_pub).expect("bob derive");
37//! assert_eq!(s1, s2);
38//! ```
39//!
40//! ## Public API (Stand 1.0.0-rc.1)
41//!
42//! - [`KeyExchange`] + [`KxSuite::X25519`] / [`KxSuite::EcdhP256`] —
43//!   Ephemeral-DH-Roundtrip mit deterministischer SharedSecret-Ableitung.
44//!
45//! ## Nicht-Ziele
46//!
47//! RSA-OAEP-Key-Transport (Spec §8.3.2.11 als optionale Alternative fuer
48//! Legacy-Vendors ohne ECDH/X25519) ist explizit nicht in RC1: alle
49//! relevanten Vendoren (Cyclone DDS, FastDDS, RTI Connext) sprechen
50//! ECDH oder X25519, und `ring 0.17` exponiert keine RSA-Encrypt-API.
51//! Falls ein konkreter Legacy-Use-Case auftaucht, wird der Pfad ueber
52//! die `rsa`-Crate als Major-2.0-additive-Erweiterung wieder eingefuehrt.
53
54#![cfg_attr(not(feature = "std"), no_std)]
55#![forbid(unsafe_code)]
56#![warn(missing_docs)]
57
58extern crate alloc;
59
60use alloc::vec::Vec;
61
62use ring::agreement::{self, ECDH_P256, EphemeralPrivateKey, PublicKey, X25519};
63use ring::hkdf;
64use ring::rand::SystemRandom;
65use zerodds_security::error::{SecurityError, SecurityErrorKind, SecurityResult};
66
67/// DH-Suite-Auswahl.
68///
69/// Default ist `X25519` — moderner Standard, 32-byte Public-Key.
70/// `EcdhP256` fuer Interop mit Vendors die **kein** X25519 kennen
71/// (RTI-Connext-Legacy, Fast-DDS < 2.7).
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum KxSuite {
74    /// X25519 (Curve25519). 32-byte Public-Key.
75    X25519,
76    /// NIST P-256 ECDH. 65-byte unkomprimierter Public-Key (0x04 || X || Y).
77    EcdhP256,
78}
79
80impl Default for KxSuite {
81    fn default() -> Self {
82        Self::X25519
83    }
84}
85
86impl KxSuite {
87    /// Erwartete Laenge des Public-Key-Bytes.
88    #[must_use]
89    pub const fn public_key_len(self) -> usize {
90        match self {
91            Self::X25519 => 32,
92            Self::EcdhP256 => 65,
93        }
94    }
95
96    fn algorithm(self) -> &'static agreement::Algorithm {
97        match self {
98            Self::X25519 => &X25519,
99            Self::EcdhP256 => &ECDH_P256,
100        }
101    }
102}
103
104/// Ephemerales DH-Schluesselpaar fuer einen einzelnen Handshake.
105///
106/// Nach `derive_shared_secret` ist die Instance verbraucht — das
107/// `EphemeralPrivateKey` erlaubt nach API-Design von `ring` nur einen
108/// `agree_ephemeral`-Call, was PFS (Perfect Forward Secrecy) erzwingt.
109pub struct KeyExchange {
110    suite: KxSuite,
111    private: EphemeralPrivateKey,
112    public: PublicKey,
113}
114
115impl KeyExchange {
116    /// Erzeugt ein frisches X25519-Schluesselpaar (Default).
117    ///
118    /// # Errors
119    /// `CryptoFailed` wenn die System-RNG nicht verfuegbar ist (z.B.
120    /// kein `/dev/urandom` in einem broken-Sandbox-Szenario).
121    pub fn new() -> SecurityResult<Self> {
122        Self::with_suite(KxSuite::X25519)
123    }
124
125    /// Erzeugt ein Schluesselpaar fuer die gewaehlte DH-Suite.
126    ///
127    /// # Errors
128    /// siehe [`Self::new`].
129    pub fn with_suite(suite: KxSuite) -> SecurityResult<Self> {
130        let rng = SystemRandom::new();
131        let private = EphemeralPrivateKey::generate(suite.algorithm(), &rng).map_err(|_| {
132            SecurityError::new(
133                SecurityErrorKind::CryptoFailed,
134                "keyexchange: ephemeral-key generation failed",
135            )
136        })?;
137        let public = private.compute_public_key().map_err(|_| {
138            SecurityError::new(
139                SecurityErrorKind::CryptoFailed,
140                "keyexchange: public-key derivation failed",
141            )
142        })?;
143        Ok(Self {
144            suite,
145            private,
146            public,
147        })
148    }
149
150    /// Liefert die aktive DH-Suite.
151    #[must_use]
152    pub fn suite(&self) -> KxSuite {
153        self.suite
154    }
155
156    /// Liefert den lokalen Public-Key als Byte-Slice. Laenge ist
157    /// suite-abhaengig (siehe [`KxSuite::public_key_len`]).
158    #[must_use]
159    pub fn public_key(&self) -> &[u8] {
160        self.public.as_ref()
161    }
162
163    /// Leitet das SharedSecret aus dem Remote-Public-Key ab. Verbraucht
164    /// das lokale ephemerale Key.
165    ///
166    /// # Errors
167    /// * `BadArgument` wenn `remote_public_key.len() != suite.public_key_len()`.
168    /// * `CryptoFailed` wenn `ring` das Agreement ablehnt (z.B.
169    ///   kleiner-Untergruppen-Angriff, Identity-Punkt, Off-Curve-Point).
170    pub fn derive_shared_secret(self, remote_public_key: &[u8]) -> SecurityResult<Vec<u8>> {
171        if remote_public_key.len() != self.suite.public_key_len() {
172            return Err(SecurityError::new(
173                SecurityErrorKind::BadArgument,
174                alloc::format!(
175                    "keyexchange: {:?} public-key muss {} byte sein",
176                    self.suite,
177                    self.suite.public_key_len()
178                ),
179            ));
180        }
181        let peer = agreement::UnparsedPublicKey::new(self.suite.algorithm(), remote_public_key);
182        agreement::agree_ephemeral(self.private, &peer, |raw_dh| {
183            // HKDF-SHA256 über den rohen DH-Output erzwingt eine
184            // uniforme Verteilung + erlaubt domain-separation.
185            let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, b"zerodds-security-v1/shared-secret");
186            let prk = salt.extract(raw_dh);
187            let info_parts = [b"DDS:Auth:PKI-DH:secret".as_slice()];
188            let okm = prk.expand(&info_parts, hkdf::HKDF_SHA256).map_err(|_| {
189                SecurityError::new(
190                    SecurityErrorKind::CryptoFailed,
191                    "keyexchange: HKDF expand failed",
192                )
193            })?;
194            let mut out = [0u8; 32];
195            okm.fill(&mut out).map_err(|_| {
196                SecurityError::new(
197                    SecurityErrorKind::CryptoFailed,
198                    "keyexchange: HKDF fill failed",
199                )
200            })?;
201            Ok(out.to_vec())
202        })
203        .map_err(|_| {
204            SecurityError::new(
205                SecurityErrorKind::CryptoFailed,
206                "keyexchange: DH agreement rejected (invalid peer key?)",
207            )
208        })
209        .and_then(|r| r)
210    }
211}
212
213#[cfg(test)]
214#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn public_key_is_32_bytes() {
220        let kx = KeyExchange::new().unwrap();
221        assert_eq!(kx.public_key().len(), 32);
222    }
223
224    #[test]
225    fn two_parties_derive_identical_secret() {
226        let alice = KeyExchange::new().unwrap();
227        let bob = KeyExchange::new().unwrap();
228
229        let a_pub = alice.public_key().to_vec();
230        let b_pub = bob.public_key().to_vec();
231
232        let s1 = alice.derive_shared_secret(&b_pub).unwrap();
233        let s2 = bob.derive_shared_secret(&a_pub).unwrap();
234
235        assert_eq!(s1.len(), 32);
236        assert_eq!(s1, s2, "alice + bob muessen identisches secret ableiten");
237    }
238
239    #[test]
240    fn different_pairs_produce_different_secrets() {
241        let alice = KeyExchange::new().unwrap();
242        let bob = KeyExchange::new().unwrap();
243        let b_pub = bob.public_key().to_vec();
244        let s1 = alice.derive_shared_secret(&b_pub).unwrap();
245
246        let alice2 = KeyExchange::new().unwrap();
247        let bob2 = KeyExchange::new().unwrap();
248        let b2_pub = bob2.public_key().to_vec();
249        let s2 = alice2.derive_shared_secret(&b2_pub).unwrap();
250
251        assert_ne!(s1, s2, "andere ephemerals → anderes secret (PFS)");
252    }
253
254    #[test]
255    fn wrong_length_public_key_rejected() {
256        let alice = KeyExchange::new().unwrap();
257        let err = alice.derive_shared_secret(&[0u8; 16]).unwrap_err();
258        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
259    }
260
261    #[test]
262    fn zero_public_key_rejected_by_ring() {
263        // All-zero X25519 public key → agreement muss failen
264        // (Identity-Punkt / kleines-Untergruppen-Angriff).
265        let alice = KeyExchange::new().unwrap();
266        let err = alice.derive_shared_secret(&[0u8; 32]).unwrap_err();
267        assert_eq!(err.kind, SecurityErrorKind::CryptoFailed);
268    }
269
270    // -------------------------------------------------------------
271    // P-256 ECDH
272    // -------------------------------------------------------------
273
274    #[test]
275    fn default_suite_is_x25519() {
276        let kx = KeyExchange::new().unwrap();
277        assert_eq!(kx.suite(), KxSuite::X25519);
278        assert_eq!(kx.public_key().len(), 32);
279    }
280
281    #[test]
282    fn p256_public_key_is_65_bytes() {
283        let kx = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
284        assert_eq!(kx.suite(), KxSuite::EcdhP256);
285        assert_eq!(kx.public_key().len(), 65);
286        // Unkomprimiertes Format startet mit 0x04.
287        assert_eq!(kx.public_key()[0], 0x04);
288    }
289
290    #[test]
291    fn p256_two_parties_derive_identical_secret() {
292        let alice = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
293        let bob = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
294        let a_pub = alice.public_key().to_vec();
295        let b_pub = bob.public_key().to_vec();
296        let s1 = alice.derive_shared_secret(&b_pub).unwrap();
297        let s2 = bob.derive_shared_secret(&a_pub).unwrap();
298        assert_eq!(s1.len(), 32);
299        assert_eq!(s1, s2);
300    }
301
302    #[test]
303    fn p256_rejects_wrong_length_public_key() {
304        let alice = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
305        let err = alice.derive_shared_secret(&[0u8; 32]).unwrap_err();
306        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
307    }
308
309    #[test]
310    fn p256_rejects_off_curve_point() {
311        // 65-byte, beginnt mit 0x04, aber der Rest ist Nullbytes →
312        // keine gueltige P-256-Punkt.
313        let alice = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
314        let mut bogus = [0u8; 65];
315        bogus[0] = 0x04;
316        let err = alice.derive_shared_secret(&bogus).unwrap_err();
317        assert_eq!(err.kind, SecurityErrorKind::CryptoFailed);
318    }
319
320    #[test]
321    fn x25519_and_p256_produce_different_public_key_lengths() {
322        let a = KeyExchange::new().unwrap();
323        let b = KeyExchange::with_suite(KxSuite::EcdhP256).unwrap();
324        assert_ne!(a.public_key().len(), b.public_key().len());
325    }
326}