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}