Skip to main content

zerodds_security_crypto/
suite.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Crypto-Suite-Auswahl (AES-GCM 128 / 256).
5//!
6//! OMG DDS-Security 1.2 §10.5 Tab.79 definiert die `CryptoTransform-
7//! Kind`-Konstanten — 4-byte Big-Endian-Werte:
8//!
9//! | Spec-Konstante                              | Hex-Low | Algorithmus       | Key |
10//! |---------------------------------------------|--------:|-------------------|----:|
11//! | `CRYPTO_TRANSFORMATION_KIND_NONE`           |  `0x00` | (kein Schutz)     |  -  |
12//! | `CRYPTO_TRANSFORMATION_KIND_AES128_GMAC`    |  `0x01` | AES-128 (GMAC)    | 16  |
13//! | `CRYPTO_TRANSFORMATION_KIND_AES128_GCM`     |  `0x02` | AES-128 (GCM)     | 16  |
14//! | `CRYPTO_TRANSFORMATION_KIND_AES256_GMAC`    |  `0x03` | AES-256 (GMAC)    | 32  |
15//! | `CRYPTO_TRANSFORMATION_KIND_AES256_GCM`     |  `0x04` | AES-256 (GCM)     | 32  |
16//!
17//! Beide GCM-Varianten liefern Integrity + Confidentiality; die GMAC-
18//! Varianten sind Auth-only (Payload bleibt plain, Tag wird angehaengt).
19//!
20//! **C3.6 Wire-Konflikt-Fix (2026-04-25):** vor diesem Commit hatte
21//! `Suite::transform_kind_id()` ein nicht-spec-konformes Mapping
22//! (Aes128Gcm=0x01, Aes256Gcm=0x02, HmacSha256=0x03), wodurch ein
23//! Cyclone DDS-Receiver unsere Submessages mit "wrong algorithm"
24//! abwies. Jetzt spec-konform — Wire-breaking Aenderung gegenueber
25//! v0.x ZeroDDS-Peers.
26
27use ring::aead;
28
29/// `CryptoTransformKind`-Konstanten als Low-Byte-IDs (Spec-1.2 §10.5
30/// Tab.79). Auf der Wire steht jeweils das 4-byte Big-Endian-Array
31/// `[0, 0, 0, ID]`.
32///
33/// `NONE` und `AES256_GMAC` werden von ZeroDDS aktuell nicht als
34/// `Suite`-Variante exposed — die Konstanten sind dennoch da fuer
35/// Decoder-Pfade die Spec-Werte erkennen muessen ("supported" vs
36/// "known but not implemented").
37#[allow(dead_code)]
38pub mod transform_kind {
39    /// `CRYPTO_TRANSFORMATION_KIND_NONE` — kein Schutz.
40    pub const NONE: u8 = 0x00;
41    /// `CRYPTO_TRANSFORMATION_KIND_AES128_GMAC` — AES-128 Auth-only.
42    pub const AES128_GMAC: u8 = 0x01;
43    /// `CRYPTO_TRANSFORMATION_KIND_AES128_GCM` — AES-128 Auth+Encrypt.
44    pub const AES128_GCM: u8 = 0x02;
45    /// `CRYPTO_TRANSFORMATION_KIND_AES256_GMAC` — AES-256 Auth-only.
46    pub const AES256_GMAC: u8 = 0x03;
47    /// `CRYPTO_TRANSFORMATION_KIND_AES256_GCM` — AES-256 Auth+Encrypt.
48    pub const AES256_GCM: u8 = 0x04;
49}
50
51/// Verfuegbare Crypto-Suites im `AesGcmCryptoPlugin`.
52///
53/// Default (`AesGcmCryptoPlugin::new()`) ist `Aes128Gcm` — leichtgewichtig,
54/// ausreichend fuer < 10-Jahres-Vertraulichkeit. Fuer hoehere Stufen
55/// `AesGcmCryptoPlugin::with_suite(Suite::Aes256Gcm)` waehlen.
56///
57/// `HmacSha256` ist **Auth-only** (keine Confidentiality). Wird
58/// eingesetzt wenn Governance-XML `metadata_protection_kind=SIGN`
59/// vorgibt — die Payload bleibt plain, aber per HMAC-Tag
60/// authentifiziert.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Suite {
63    /// AES-128 im GCM-Mode. 16-byte Master-Key. Auth+Encrypt.
64    Aes128Gcm,
65    /// AES-256 im GCM-Mode. 32-byte Master-Key. Auth+Encrypt.
66    Aes256Gcm,
67    /// HMAC-SHA256 Auth-only. 32-byte Master-Key. Payload bleibt
68    /// plain; 32-byte-HMAC wird angehaengt (Spec-Kind
69    /// `NONE + HMAC_SHA256` — "SIGN" im Governance-XML).
70    HmacSha256,
71}
72
73impl Suite {
74    /// Benötigte Master-Key-Laenge in Bytes.
75    #[must_use]
76    pub const fn key_len(self) -> usize {
77        match self {
78            Self::Aes128Gcm => 16,
79            Self::Aes256Gcm | Self::HmacSha256 => 32,
80        }
81    }
82
83    /// ring-Algorithm-Referenz fuer AEAD. Fuer `HmacSha256` gibts
84    /// keine AEAD-Algo → Caller muss diese Suite separat handlen.
85    #[must_use]
86    pub(crate) fn algorithm(self) -> Option<&'static aead::Algorithm> {
87        match self {
88            Self::Aes128Gcm => Some(&aead::AES_128_GCM),
89            Self::Aes256Gcm => Some(&aead::AES_256_GCM),
90            Self::HmacSha256 => None,
91        }
92    }
93
94    /// `true` wenn die Suite Confidentiality liefert (sonst Auth-only).
95    #[must_use]
96    pub const fn is_aead(self) -> bool {
97        matches!(self, Self::Aes128Gcm | Self::Aes256Gcm)
98    }
99
100    /// Ein-Byte-Transform-Kind-Id fuers Wire-Format (SEC_PREFIX).
101    /// Spec DDS-Security 1.2 §10.5 Tab.79 — wir liefern den Low-Byte-
102    /// Anteil; der Wire-Codec packt das in `[0, 0, 0, id]` BE.
103    ///
104    /// `HmacSha256` belegt den `AES128_GMAC`-Slot (`0x01`) — die
105    /// ZeroDDS-Implementation laeuft mit HMAC-SHA256 statt AES-GMAC,
106    /// d.h. fuer SIGN-only-Topics ist der Cyclone-Interop noch nicht
107    /// gegeben (siehe C3.7). GCM-Varianten sind voll spec-konform.
108    #[must_use]
109    pub fn transform_kind_id(self) -> u8 {
110        match self {
111            Self::Aes128Gcm => transform_kind::AES128_GCM, // 0x02
112            Self::Aes256Gcm => transform_kind::AES256_GCM, // 0x04
113            Self::HmacSha256 => transform_kind::AES128_GMAC, // 0x01 (Slot)
114        }
115    }
116
117    /// 4-byte `CryptoTransformKind` Big-Endian-Wire-Repraesentation
118    /// (Spec §10.5 Tab.79).
119    #[must_use]
120    pub fn transform_kind(self) -> [u8; 4] {
121        [0, 0, 0, self.transform_kind_id()]
122    }
123
124    /// Inverse von [`Self::transform_kind_id`]. Liefert `None` fuer
125    /// Spec-Werte die ZeroDDS nicht als Suite anbietet (z.B.
126    /// `AES256_GMAC = 0x03`).
127    #[must_use]
128    pub fn from_transform_kind_id(id: u8) -> Option<Self> {
129        match id {
130            transform_kind::AES128_GMAC => Some(Self::HmacSha256),
131            transform_kind::AES128_GCM => Some(Self::Aes128Gcm),
132            transform_kind::AES256_GCM => Some(Self::Aes256Gcm),
133            _ => None,
134        }
135    }
136
137    /// Maximum Encrypts pro Key, bevor Key-Refresh noetig wird. Spec
138    /// §9.5.3.3.4 empfiehlt ≤ 2^32 fuer GCM — wir cappen bei 2^48
139    /// (konservativ unter Soft-Limit, viel unter harter Nonce-Bound).
140    #[must_use]
141    pub const fn max_encrypts(self) -> u64 {
142        // 2^48 = 281_474_976_710_656
143        1u64 << 48
144    }
145}
146
147impl Default for Suite {
148    fn default() -> Self {
149        Self::Aes128Gcm
150    }
151}
152
153#[cfg(test)]
154#[allow(clippy::expect_used, clippy::unwrap_used)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn spec_constants_match_table_79() {
160        // Spec DDS-Security 1.2 §10.5 Tab.79 — Wire-Werte muessen
161        // unveraenderlich sein. Cyclone DDS / FastDDS verlassen sich
162        // auf genau diese Bytes.
163        assert_eq!(transform_kind::NONE, 0x00);
164        assert_eq!(transform_kind::AES128_GMAC, 0x01);
165        assert_eq!(transform_kind::AES128_GCM, 0x02);
166        assert_eq!(transform_kind::AES256_GMAC, 0x03);
167        assert_eq!(transform_kind::AES256_GCM, 0x04);
168    }
169
170    #[test]
171    fn aes128_gcm_uses_spec_id_2() {
172        assert_eq!(Suite::Aes128Gcm.transform_kind_id(), 0x02);
173        assert_eq!(Suite::Aes128Gcm.transform_kind(), [0, 0, 0, 0x02]);
174    }
175
176    #[test]
177    fn aes256_gcm_uses_spec_id_4() {
178        assert_eq!(Suite::Aes256Gcm.transform_kind_id(), 0x04);
179        assert_eq!(Suite::Aes256Gcm.transform_kind(), [0, 0, 0, 0x04]);
180    }
181
182    #[test]
183    fn hmac_sha256_uses_aes128_gmac_slot() {
184        // ZeroDDS-Drift: Implementation ist HMAC-SHA256, der Wire-Slot
185        // ist aber AES128_GMAC=0x01. SIGN-only-Cross-Vendor (z.B.
186        // Cyclone) ist hier noch nicht spec-konform; siehe C3.7.
187        assert_eq!(Suite::HmacSha256.transform_kind_id(), 0x01);
188    }
189
190    #[test]
191    fn from_id_roundtrip_for_all_supported() {
192        assert_eq!(Suite::from_transform_kind_id(0x01), Some(Suite::HmacSha256));
193        assert_eq!(Suite::from_transform_kind_id(0x02), Some(Suite::Aes128Gcm));
194        assert_eq!(Suite::from_transform_kind_id(0x04), Some(Suite::Aes256Gcm));
195    }
196
197    #[test]
198    fn from_id_rejects_unsupported() {
199        // 0x00 NONE — wir bieten keine None-Suite an
200        assert!(Suite::from_transform_kind_id(0x00).is_none());
201        // 0x03 AES256_GMAC — Spec-konform, aber ZeroDDS bietet sie nicht
202        assert!(Suite::from_transform_kind_id(0x03).is_none());
203        // Reservierter / unbekannter Wert
204        assert!(Suite::from_transform_kind_id(0x99).is_none());
205    }
206
207    #[test]
208    fn each_suite_roundtrips_id() {
209        for s in [Suite::Aes128Gcm, Suite::Aes256Gcm, Suite::HmacSha256] {
210            let id = s.transform_kind_id();
211            assert_eq!(Suite::from_transform_kind_id(id), Some(s));
212        }
213    }
214}