Skip to main content

zerodds_security_crypto/
crypto_transform.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `CryptoTransformIdentifier` und Builtin-Crypto-Plugin-IDs —
5//! DDS-Security 1.2 §7.3.20 + §10.5.2-3 + §10.3.2.1.
6//!
7//! Spec §7.3.20 (S. 73) listet die builtin Cryptographic-Plugin-IDs;
8//! §10.5.2 spezifiziert die `CryptoTransformIdentifier`-Struktur, die
9//! in jeder verschluesselten RTPS-Submessage als Identifier-Header
10//! mitlaeuft.
11
12use alloc::format;
13use alloc::vec::Vec;
14
15/// Builtin Crypto Plugin Class-ID (Spec §10.3.2.1 + §7.3.20).
16pub const BUILTIN_CRYPTO_PLUGIN: &str = "DDS:Crypto:AES_GCM_GMAC";
17
18/// Spec §10.5.2.1: `CryptoTransformKind` — 4-Byte-Identifier.
19///
20/// ```text
21///   CRYPTO_TRANSFORMATION_KIND_NONE      = {0,0,0,0}
22///   CRYPTO_TRANSFORMATION_KIND_AES128_GMAC = {0,0,0,1}
23///   CRYPTO_TRANSFORMATION_KIND_AES128_GCM  = {0,0,0,2}
24///   CRYPTO_TRANSFORMATION_KIND_AES256_GMAC = {0,0,0,3}
25///   CRYPTO_TRANSFORMATION_KIND_AES256_GCM  = {0,0,0,4}
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[repr(u32)]
29pub enum CryptoTransformKind {
30    /// Kein Transform — Submessage unsigned.
31    None = 0,
32    /// AES-128-GMAC (nur Authentication, kein Encrypt).
33    Aes128Gmac = 1,
34    /// AES-128-GCM (Authentication + Encrypt).
35    Aes128Gcm = 2,
36    /// AES-256-GMAC.
37    Aes256Gmac = 3,
38    /// AES-256-GCM.
39    Aes256Gcm = 4,
40}
41
42impl CryptoTransformKind {
43    /// Wire-Bytes (4 BE).
44    #[must_use]
45    pub const fn to_be_bytes(self) -> [u8; 4] {
46        (self as u32).to_be_bytes()
47    }
48
49    /// Decode aus 4 BE Bytes.
50    ///
51    /// # Errors
52    /// Static-String wenn Wert nicht in 0..=4 liegt.
53    pub fn from_be_bytes(bytes: [u8; 4]) -> Result<Self, &'static str> {
54        match u32::from_be_bytes(bytes) {
55            0 => Ok(Self::None),
56            1 => Ok(Self::Aes128Gmac),
57            2 => Ok(Self::Aes128Gcm),
58            3 => Ok(Self::Aes256Gmac),
59            4 => Ok(Self::Aes256Gcm),
60            _ => Err("unknown CryptoTransformKind"),
61        }
62    }
63
64    /// `true` wenn der Transform Encryption macht (sonst nur Auth).
65    #[must_use]
66    pub const fn encrypts(self) -> bool {
67        matches!(self, Self::Aes128Gcm | Self::Aes256Gcm)
68    }
69
70    /// Spec §10.5.2.1 — Authentication-Tag-Length.
71    /// Alle GCM/GMAC-Varianten verwenden 16 Byte Tag.
72    #[must_use]
73    pub const fn tag_size(self) -> usize {
74        match self {
75            Self::None => 0,
76            _ => 16,
77        }
78    }
79
80    /// Key-Material-Size (Bytes) je Algorithmus.
81    #[must_use]
82    pub const fn key_size(self) -> usize {
83        match self {
84            Self::None => 0,
85            Self::Aes128Gmac | Self::Aes128Gcm => 16,
86            Self::Aes256Gmac | Self::Aes256Gcm => 32,
87        }
88    }
89}
90
91/// Spec §10.5.2.1 `CryptoTransformIdentifier`:
92/// ```text
93///   struct CryptoTransformIdentifier {
94///     CryptoTransformKind   transformation_kind; // 4 byte BE
95///     CryptoTransformKeyId  transformation_key_id; // 4 byte
96///   };
97/// ```
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct CryptoTransformIdentifier {
100    /// 4-Byte-Wire-Form.
101    pub kind: CryptoTransformKind,
102    /// 4-Byte Key-Identifier (vom Plugin vergeben).
103    pub key_id: [u8; 4],
104}
105
106impl CryptoTransformIdentifier {
107    /// Konstruktor.
108    #[must_use]
109    pub fn new(kind: CryptoTransformKind, key_id: [u8; 4]) -> Self {
110        Self { kind, key_id }
111    }
112
113    /// Encode zu 8 Bytes (Spec Wire-Form).
114    #[must_use]
115    pub fn to_bytes(&self) -> [u8; 8] {
116        let mut out = [0u8; 8];
117        out[0..4].copy_from_slice(&self.kind.to_be_bytes());
118        out[4..8].copy_from_slice(&self.key_id);
119        out
120    }
121
122    /// Decode 8 Bytes.
123    ///
124    /// # Errors
125    /// Static-String wenn der Wire-Buffer nicht 8 Bytes hat oder
126    /// die `transformation_kind` ungueltig ist.
127    pub fn from_bytes(bytes: &[u8]) -> Result<Self, &'static str> {
128        if bytes.len() != 8 {
129            return Err("CryptoTransformIdentifier needs 8 bytes");
130        }
131        let mut k = [0u8; 4];
132        k.copy_from_slice(&bytes[0..4]);
133        let kind = CryptoTransformKind::from_be_bytes(k)?;
134        let mut key_id = [0u8; 4];
135        key_id.copy_from_slice(&bytes[4..8]);
136        Ok(Self { kind, key_id })
137    }
138}
139
140/// Spec §10.5.2.3 `CryptoHeader`:
141/// ```text
142///   struct CryptoHeader {
143///     CryptoTransformIdentifier transformation_id;
144///     CryptoSessionId           session_id;        // 4 byte
145///     CryptoInitVectorSuffix    init_vector_suffix; // 8 byte
146///   };
147/// ```
148/// Total 8 + 4 + 8 = 20 Bytes.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct CryptoHeader {
151    /// Transform-Identifier.
152    pub transformation_id: CryptoTransformIdentifier,
153    /// Session-Id (vom Plugin vergeben, 4 Byte).
154    pub session_id: [u8; 4],
155    /// IV-Suffix (8 Byte). Volles IV = session_id || init_vector_suffix.
156    pub init_vector_suffix: [u8; 8],
157}
158
159impl CryptoHeader {
160    /// Wire-Size (Spec).
161    pub const WIRE_SIZE: usize = 20;
162
163    /// Encode zu Wire-Bytes (20 Byte).
164    #[must_use]
165    pub fn to_bytes(&self) -> [u8; Self::WIRE_SIZE] {
166        let mut out = [0u8; Self::WIRE_SIZE];
167        out[0..8].copy_from_slice(&self.transformation_id.to_bytes());
168        out[8..12].copy_from_slice(&self.session_id);
169        out[12..20].copy_from_slice(&self.init_vector_suffix);
170        out
171    }
172
173    /// Decode 20 Wire-Bytes.
174    ///
175    /// # Errors
176    /// Static-String wenn Wire-Buffer falscher Laenge.
177    pub fn from_bytes(bytes: &[u8]) -> Result<Self, &'static str> {
178        if bytes.len() < Self::WIRE_SIZE {
179            return Err("CryptoHeader needs 20 bytes");
180        }
181        let transformation_id = CryptoTransformIdentifier::from_bytes(&bytes[0..8])?;
182        let mut session_id = [0u8; 4];
183        session_id.copy_from_slice(&bytes[8..12]);
184        let mut iv_suffix = [0u8; 8];
185        iv_suffix.copy_from_slice(&bytes[12..20]);
186        Ok(Self {
187            transformation_id,
188            session_id,
189            init_vector_suffix: iv_suffix,
190        })
191    }
192
193    /// Berechnet das volle 12-Byte-IV (Spec §10.5.2.3): `session_id ||
194    /// init_vector_suffix`. Wird in AES-GCM als Nonce verwendet.
195    #[must_use]
196    pub fn full_iv(&self) -> [u8; 12] {
197        let mut iv = [0u8; 12];
198        iv[0..4].copy_from_slice(&self.session_id);
199        iv[4..12].copy_from_slice(&self.init_vector_suffix);
200        iv
201    }
202}
203
204/// Spec §10.5.2.4 `CryptoFooter` — enthaelt Auth-Tag + Receiver-
205/// Specific-MAC-Liste. Wir liefern nur den minimalen Common-Tag.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct CryptoFooter {
208    /// Common 16-Byte AES-GCM/GMAC Authentication-Tag.
209    pub common_mac: [u8; 16],
210    /// Receiver-Specific-MACs (Spec §10.5.2.4 — pro Empfaenger ein
211    /// 4-Byte-Key-Id + 16-Byte-Tag; voll abgedeckt in WP A.6).
212    pub receiver_specific_macs: Vec<([u8; 4], [u8; 16])>,
213}
214
215impl CryptoFooter {
216    /// Encode (4-Byte BE Receiver-Count + 16 common_mac + N x (4+16)).
217    #[must_use]
218    pub fn to_bytes(&self) -> Vec<u8> {
219        let mut out = Vec::with_capacity(20 + self.receiver_specific_macs.len() * 20);
220        out.extend_from_slice(&self.common_mac);
221        let n = self.receiver_specific_macs.len() as u32;
222        out.extend_from_slice(&n.to_be_bytes());
223        for (key_id, mac) in &self.receiver_specific_macs {
224            out.extend_from_slice(key_id);
225            out.extend_from_slice(mac);
226        }
227        out
228    }
229
230    /// Decode aus Wire-Bytes.
231    ///
232    /// # Errors
233    /// Static-String bei falscher Laenge.
234    pub fn from_bytes(bytes: &[u8]) -> Result<Self, &'static str> {
235        if bytes.len() < 20 {
236            return Err("CryptoFooter needs >= 20 bytes");
237        }
238        let mut common_mac = [0u8; 16];
239        common_mac.copy_from_slice(&bytes[0..16]);
240        let n_buf: [u8; 4] = bytes[16..20].try_into().map_err(|_| "footer count")?;
241        let n = u32::from_be_bytes(n_buf) as usize;
242        let mut pos = 20;
243        let mut receiver_specific_macs = Vec::with_capacity(n);
244        for _ in 0..n {
245            if bytes.len() < pos + 20 {
246                return Err("receiver-specific MAC truncated");
247            }
248            let mut key_id = [0u8; 4];
249            key_id.copy_from_slice(&bytes[pos..pos + 4]);
250            let mut mac = [0u8; 16];
251            mac.copy_from_slice(&bytes[pos + 4..pos + 20]);
252            receiver_specific_macs.push((key_id, mac));
253            pos += 20;
254        }
255        Ok(Self {
256            common_mac,
257            receiver_specific_macs,
258        })
259    }
260}
261
262/// Cross-Vendor-Plugin-Negotiation. Caller liefert die Liste der
263/// vom Remote angekuendigten Plugin-Class-Ids; wir liefern den
264/// hoechsten gemeinsam unterstuetzten Builtin-Algorithmus zurueck.
265///
266/// # Errors
267/// `format!` String wenn kein gemeinsamer Algorithmus.
268pub fn negotiate_transform(
269    remote_kinds: &[CryptoTransformKind],
270) -> Result<CryptoTransformKind, alloc::string::String> {
271    // Praeferenz: AES-256-GCM > AES-128-GCM > AES-256-GMAC > AES-128-GMAC.
272    let pref = [
273        CryptoTransformKind::Aes256Gcm,
274        CryptoTransformKind::Aes128Gcm,
275        CryptoTransformKind::Aes256Gmac,
276        CryptoTransformKind::Aes128Gmac,
277    ];
278    for p in pref {
279        if remote_kinds.contains(&p) {
280            return Ok(p);
281        }
282    }
283    Err(format!(
284        "no common crypto transform with peer (peer-offered: {remote_kinds:?})"
285    ))
286}
287
288#[cfg(test)]
289#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn kind_round_trip_all_variants() {
295        for k in [
296            CryptoTransformKind::None,
297            CryptoTransformKind::Aes128Gmac,
298            CryptoTransformKind::Aes128Gcm,
299            CryptoTransformKind::Aes256Gmac,
300            CryptoTransformKind::Aes256Gcm,
301        ] {
302            assert_eq!(
303                CryptoTransformKind::from_be_bytes(k.to_be_bytes()).unwrap(),
304                k
305            );
306        }
307    }
308
309    #[test]
310    fn unknown_kind_rejected() {
311        assert!(CryptoTransformKind::from_be_bytes([0, 0, 0, 99]).is_err());
312    }
313
314    #[test]
315    fn key_sizes_match_spec() {
316        assert_eq!(CryptoTransformKind::Aes128Gcm.key_size(), 16);
317        assert_eq!(CryptoTransformKind::Aes256Gcm.key_size(), 32);
318        assert_eq!(CryptoTransformKind::None.key_size(), 0);
319    }
320
321    #[test]
322    fn tag_size_is_16_for_all_aead_variants() {
323        for k in [
324            CryptoTransformKind::Aes128Gmac,
325            CryptoTransformKind::Aes128Gcm,
326            CryptoTransformKind::Aes256Gmac,
327            CryptoTransformKind::Aes256Gcm,
328        ] {
329            assert_eq!(k.tag_size(), 16);
330        }
331    }
332
333    #[test]
334    fn encrypts_only_for_gcm() {
335        assert!(CryptoTransformKind::Aes128Gcm.encrypts());
336        assert!(CryptoTransformKind::Aes256Gcm.encrypts());
337        assert!(!CryptoTransformKind::Aes128Gmac.encrypts());
338        assert!(!CryptoTransformKind::None.encrypts());
339    }
340
341    #[test]
342    fn transform_identifier_round_trip() {
343        let id = CryptoTransformIdentifier::new(
344            CryptoTransformKind::Aes256Gcm,
345            [0xCA, 0xFE, 0xBA, 0xBE],
346        );
347        let bytes = id.to_bytes();
348        assert_eq!(bytes.len(), 8);
349        let back = CryptoTransformIdentifier::from_bytes(&bytes).unwrap();
350        assert_eq!(back, id);
351    }
352
353    #[test]
354    fn header_round_trip_with_full_iv() {
355        let h = CryptoHeader {
356            transformation_id: CryptoTransformIdentifier::new(
357                CryptoTransformKind::Aes256Gcm,
358                [1, 2, 3, 4],
359            ),
360            session_id: [10, 20, 30, 40],
361            init_vector_suffix: [50, 60, 70, 80, 90, 100, 110, 120],
362        };
363        let bytes = h.to_bytes();
364        assert_eq!(bytes.len(), CryptoHeader::WIRE_SIZE);
365        let back = CryptoHeader::from_bytes(&bytes).unwrap();
366        assert_eq!(back, h);
367        assert_eq!(
368            back.full_iv(),
369            [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
370        );
371    }
372
373    #[test]
374    fn header_short_buffer_rejected() {
375        assert!(CryptoHeader::from_bytes(&[0; 10]).is_err());
376    }
377
378    #[test]
379    fn footer_round_trip_no_receivers() {
380        let f = CryptoFooter {
381            common_mac: [0xAA; 16],
382            receiver_specific_macs: alloc::vec![],
383        };
384        let bytes = f.to_bytes();
385        let back = CryptoFooter::from_bytes(&bytes).unwrap();
386        assert_eq!(back, f);
387    }
388
389    #[test]
390    fn footer_round_trip_with_receivers() {
391        let f = CryptoFooter {
392            common_mac: [0xAA; 16],
393            receiver_specific_macs: alloc::vec![
394                ([1, 2, 3, 4], [0xBB; 16]),
395                ([5, 6, 7, 8], [0xCC; 16]),
396            ],
397        };
398        let bytes = f.to_bytes();
399        let back = CryptoFooter::from_bytes(&bytes).unwrap();
400        assert_eq!(back, f);
401    }
402
403    #[test]
404    fn footer_short_buffer_rejected() {
405        assert!(CryptoFooter::from_bytes(&[0; 10]).is_err());
406    }
407
408    #[test]
409    fn negotiate_picks_strongest_common() {
410        let r = negotiate_transform(&[
411            CryptoTransformKind::Aes128Gmac,
412            CryptoTransformKind::Aes256Gcm,
413            CryptoTransformKind::Aes128Gcm,
414        ])
415        .unwrap();
416        assert_eq!(r, CryptoTransformKind::Aes256Gcm);
417    }
418
419    #[test]
420    fn negotiate_falls_back_to_gmac_if_no_gcm() {
421        let r = negotiate_transform(&[CryptoTransformKind::Aes128Gmac]).unwrap();
422        assert_eq!(r, CryptoTransformKind::Aes128Gmac);
423    }
424
425    #[test]
426    fn negotiate_fails_with_no_overlap() {
427        assert!(negotiate_transform(&[CryptoTransformKind::None]).is_err());
428        assert!(negotiate_transform(&[]).is_err());
429    }
430
431    #[test]
432    fn builtin_plugin_id_matches_spec() {
433        // Spec §10.3.2.1 + §7.3.20.
434        assert_eq!(BUILTIN_CRYPTO_PLUGIN, "DDS:Crypto:AES_GCM_GMAC");
435    }
436}