Skip to main content

zerodds_security_runtime/
endpoint.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Endpoint-Level-Protection Abstraktion.
5//!
6//! Bruecke zwischen dem Wire-Typ [`EndpointSecurityInfo`] und der
7//! Policy-Ebene [`ProtectionLevel`]. Haelt die Match-Logik fuer ein
8//! Writer/Reader-Paar: pro Endpoint traegt der Wire einen 2x u32-
9//! Bitmask-Block, die Policy-Engine entscheidet daraus, ob ein Match
10//! zustandekommt und mit welchem resultierenden Protection-Level.
11//!
12//! # DoD-Matching-Matrix (Plan §Stufe 3)
13//!
14//! | Writer         | Reader          | Ergebnis                       |
15//! |----------------|-----------------|--------------------------------|
16//! | `Encrypt`      | keine Caps      | Match abgelehnt                |
17//! | `Sign`         | `Encrypt`       | Match, Endlevel = `Encrypt`    |
18//! | `None`         | `None`          | Match, Endlevel = `None`       |
19//! | `Encrypt`      | `Encrypt`       | Match, Endlevel = `Encrypt`    |
20//!
21//! "Staerkster Wert gewinnt": `max(writer, reader)`. Wenn einer der
22//! Endpoints kein `EndpointSecurityInfo` liefert (Legacy-Peer), wird
23//! das Paar nur akzeptiert wenn **beide** effektiv `None` fahren.
24
25use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
26
27use crate::policy::ProtectionLevel;
28
29/// Policy-Sicht auf ein Endpoint: welches Protection-Level verlangt/
30/// bietet dieser Writer/Reader.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct EndpointProtection {
33    /// Verlangtes/angebotenes Protection-Level.
34    pub level: ProtectionLevel,
35}
36
37impl EndpointProtection {
38    /// Kurzform: plaintext-Endpoint (Legacy oder bewusst `None`).
39    pub const PLAIN: Self = Self {
40        level: ProtectionLevel::None,
41    };
42
43    /// Builder.
44    #[must_use]
45    pub const fn new(level: ProtectionLevel) -> Self {
46        Self { level }
47    }
48
49    /// Ableitung aus einem [`EndpointSecurityInfo`]:
50    /// * Payload-encrypted → `Encrypt`
51    /// * Submessage-encrypted → `Encrypt`
52    /// * Submessage/Payload protected ohne Plugin-Encrypt-Flag → `Sign`
53    /// * Keine Protection-Bits → `None`
54    ///
55    /// `None` als Argument (Legacy-Endpoint) → `Self::PLAIN`.
56    #[must_use]
57    pub fn from_info(info: Option<&EndpointSecurityInfo>) -> Self {
58        let Some(info) = info else {
59            return Self::PLAIN;
60        };
61        if !info.is_valid() {
62            // Spec §7.4.1.5: bei fehlendem IS_VALID sind die Bits
63            // nicht interpretierbar. Behandle als Legacy.
64            return Self::PLAIN;
65        }
66        if info.is_payload_encrypted() || info.is_submessage_encrypted() {
67            return Self::new(ProtectionLevel::Encrypt);
68        }
69        if info.is_submessage_protected() || info.is_payload_protected() {
70            return Self::new(ProtectionLevel::Sign);
71        }
72        Self::PLAIN
73    }
74
75    /// Serialisierung zurueck in [`EndpointSecurityInfo`] — fuer
76    /// SEDP-Announce unseres eigenen Endpoints.
77    #[must_use]
78    pub fn to_info(self) -> EndpointSecurityInfo {
79        let mut endpoint = attrs::IS_VALID;
80        let mut plugin = plugin_attrs::IS_VALID;
81        match self.level {
82            ProtectionLevel::None => {}
83            ProtectionLevel::Sign => {
84                endpoint |= attrs::IS_SUBMESSAGE_PROTECTED;
85            }
86            ProtectionLevel::Encrypt => {
87                endpoint |= attrs::IS_SUBMESSAGE_PROTECTED | attrs::IS_PAYLOAD_PROTECTED;
88                plugin |=
89                    plugin_attrs::IS_SUBMESSAGE_ENCRYPTED | plugin_attrs::IS_PAYLOAD_ENCRYPTED;
90            }
91        }
92        EndpointSecurityInfo {
93            endpoint_security_attributes: endpoint,
94            plugin_endpoint_security_attributes: plugin,
95        }
96    }
97}
98
99/// Ergebnis eines Writer/Reader-Match-Checks.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum EndpointMatch {
102    /// Match akzeptiert, auf diesem effektiven Protection-Level
103    /// (`max(writer, reader)`).
104    Accept(ProtectionLevel),
105    /// Match abgelehnt — Peer kann die verlangte Protection nicht liefern.
106    Reject(MatchRejectReason),
107}
108
109/// Warum das Matching ein Reject ist.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum MatchRejectReason {
112    /// Einer der Peers hat eine Protection-Anforderung, aber kein
113    /// `EndpointSecurityInfo` geliefert → als Legacy interpretiert —
114    /// Match nur bei beidseitigem `None` zulaessig.
115    LegacyPeerVsProtection,
116}
117
118/// Match Writer ↔ Reader. Aus den beiden [`EndpointProtection`]-
119/// Werten wird bestimmt:
120/// * Ist das Paar kompatibel?
121/// * Auf welchem Level wird kommuniziert?
122///
123/// Regel (Plan §Stufe 3 DoD):
124/// * Writer `Encrypt`, Reader `None` ohne Info → `Reject`
125/// * Writer `None` ohne Info, Reader `Encrypt` → `Reject`
126/// * Writer `Sign`, Reader `Encrypt` → `Accept(Encrypt)` (stronger wins)
127/// * Alles andere → `Accept(max(w, r))`
128#[must_use]
129pub fn match_endpoints(
130    writer: &EndpointProtection,
131    reader: &EndpointProtection,
132    writer_has_info: bool,
133    reader_has_info: bool,
134) -> EndpointMatch {
135    // Wenn eine Seite Protection will und die andere ein Legacy-
136    // Endpoint ist (kein EndpointSecurityInfo), ist das ein Reject.
137    let writer_wants_protection = !matches!(writer.level, ProtectionLevel::None);
138    let reader_wants_protection = !matches!(reader.level, ProtectionLevel::None);
139    if writer_wants_protection && !reader_has_info {
140        return EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection);
141    }
142    if reader_wants_protection && !writer_has_info {
143        return EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection);
144    }
145    EndpointMatch::Accept(writer.level.stronger(reader.level))
146}
147
148// ============================================================================
149// Tests
150// ============================================================================
151
152#[cfg(test)]
153#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
154mod tests {
155    use super::*;
156
157    // ---- EndpointProtection::from_info ----
158
159    #[test]
160    fn from_info_none_is_plain() {
161        assert_eq!(
162            EndpointProtection::from_info(None),
163            EndpointProtection::PLAIN
164        );
165    }
166
167    #[test]
168    fn from_info_invalid_valid_bit_is_plain() {
169        // Flags gesetzt, aber kein IS_VALID -> Spec verbietet
170        // Interpretation -> Legacy.
171        let info = EndpointSecurityInfo {
172            endpoint_security_attributes: attrs::IS_SUBMESSAGE_PROTECTED,
173            plugin_endpoint_security_attributes: plugin_attrs::IS_SUBMESSAGE_ENCRYPTED,
174        };
175        assert_eq!(
176            EndpointProtection::from_info(Some(&info)),
177            EndpointProtection::PLAIN
178        );
179    }
180
181    #[test]
182    fn from_info_plain_legacy_is_plain() {
183        let info = EndpointSecurityInfo::plain();
184        assert_eq!(
185            EndpointProtection::from_info(Some(&info)),
186            EndpointProtection::PLAIN
187        );
188    }
189
190    #[test]
191    fn from_info_submessage_protected_without_encrypt_is_sign() {
192        let info = EndpointSecurityInfo {
193            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_SUBMESSAGE_PROTECTED,
194            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID,
195        };
196        assert_eq!(
197            EndpointProtection::from_info(Some(&info)).level,
198            ProtectionLevel::Sign
199        );
200    }
201
202    #[test]
203    fn from_info_submessage_encrypted_is_encrypt() {
204        let info = EndpointSecurityInfo {
205            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_SUBMESSAGE_PROTECTED,
206            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID
207                | plugin_attrs::IS_SUBMESSAGE_ENCRYPTED,
208        };
209        assert_eq!(
210            EndpointProtection::from_info(Some(&info)).level,
211            ProtectionLevel::Encrypt
212        );
213    }
214
215    #[test]
216    fn from_info_payload_encrypted_is_encrypt() {
217        let info = EndpointSecurityInfo {
218            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_PAYLOAD_PROTECTED,
219            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID
220                | plugin_attrs::IS_PAYLOAD_ENCRYPTED,
221        };
222        assert_eq!(
223            EndpointProtection::from_info(Some(&info)).level,
224            ProtectionLevel::Encrypt
225        );
226    }
227
228    // ---- EndpointProtection::to_info ----
229
230    #[test]
231    fn to_info_none_sets_only_valid_bits() {
232        let info = EndpointProtection::PLAIN.to_info();
233        assert!(info.is_valid());
234        assert!(!info.is_submessage_protected());
235        assert!(!info.is_payload_protected());
236        assert!(!info.is_submessage_encrypted());
237    }
238
239    #[test]
240    fn to_info_sign_sets_submessage_protected_without_encryption() {
241        let info = EndpointProtection::new(ProtectionLevel::Sign).to_info();
242        assert!(info.is_valid());
243        assert!(info.is_submessage_protected());
244        assert!(!info.is_submessage_encrypted());
245        assert!(!info.is_payload_encrypted());
246    }
247
248    #[test]
249    fn to_info_encrypt_sets_both_protection_and_encryption() {
250        let info = EndpointProtection::new(ProtectionLevel::Encrypt).to_info();
251        assert!(info.is_valid());
252        assert!(info.is_submessage_protected());
253        assert!(info.is_payload_protected());
254        assert!(info.is_submessage_encrypted());
255        assert!(info.is_payload_encrypted());
256    }
257
258    #[test]
259    fn to_info_from_info_roundtrip_for_all_levels() {
260        for lvl in [
261            ProtectionLevel::None,
262            ProtectionLevel::Sign,
263            ProtectionLevel::Encrypt,
264        ] {
265            let ep = EndpointProtection::new(lvl);
266            let info = ep.to_info();
267            let back = EndpointProtection::from_info(Some(&info));
268            assert_eq!(back, ep, "roundtrip scheitert fuer {lvl:?}");
269        }
270    }
271
272    // ---- match_endpoints — DoD-Matrix aus Plan §Stufe 3 ----
273
274    #[test]
275    fn dod_writer_encrypt_reader_no_plugin_is_reject() {
276        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
277        let r = EndpointProtection::PLAIN;
278        let result = match_endpoints(
279            &w, &r, /*writer_has_info=*/ true, /*reader_has_info=*/ false,
280        );
281        assert_eq!(
282            result,
283            EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection)
284        );
285    }
286
287    #[test]
288    fn dod_writer_sign_reader_encrypt_accepts_with_encrypt() {
289        let w = EndpointProtection::new(ProtectionLevel::Sign);
290        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
291        let result = match_endpoints(&w, &r, true, true);
292        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
293    }
294
295    #[test]
296    fn writer_none_reader_none_both_info_accepts_none() {
297        let w = EndpointProtection::PLAIN;
298        let r = EndpointProtection::PLAIN;
299        let result = match_endpoints(&w, &r, true, true);
300        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::None));
301    }
302
303    #[test]
304    fn writer_none_reader_none_both_legacy_accepts_none() {
305        // Zwei legacy-Peers ohne Security-PID — legacy ↔ legacy ist ok.
306        let w = EndpointProtection::PLAIN;
307        let r = EndpointProtection::PLAIN;
308        let result = match_endpoints(&w, &r, false, false);
309        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::None));
310    }
311
312    #[test]
313    fn writer_encrypt_reader_encrypt_accepts_encrypt() {
314        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
315        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
316        let result = match_endpoints(&w, &r, true, true);
317        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
318    }
319
320    #[test]
321    fn writer_plain_reader_encrypt_rejects_if_writer_legacy() {
322        // Writer ist Legacy (no security info), Reader will Encrypt → Reject.
323        let w = EndpointProtection::PLAIN;
324        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
325        let result = match_endpoints(&w, &r, /*writer_has_info=*/ false, true);
326        assert_eq!(
327            result,
328            EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection)
329        );
330    }
331
332    #[test]
333    fn writer_encrypt_reader_sign_accepts_with_encrypt() {
334        // Symmetrischer Fall zum DoD-Beispiel: Reader bietet SIGN,
335        // Writer ENCRYPT → stronger wins = ENCRYPT.
336        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
337        let r = EndpointProtection::new(ProtectionLevel::Sign);
338        let result = match_endpoints(&w, &r, true, true);
339        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
340    }
341
342    #[test]
343    fn writer_sign_reader_sign_accepts_with_sign() {
344        let w = EndpointProtection::new(ProtectionLevel::Sign);
345        let r = EndpointProtection::new(ProtectionLevel::Sign);
346        let result = match_endpoints(&w, &r, true, true);
347        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Sign));
348    }
349
350    #[test]
351    fn writer_none_with_info_reader_sign_rejects_only_if_writer_cant() {
352        // Writer hat SecurityInfo aber Level=None (explizit plaintext);
353        // Reader will SIGN → das ist aus Reader-Sicht nicht erfuellbar
354        // — in heutiger Version akzeptieren wir den Match, weil beide
355        // SecurityInfo haben. Das DoD erlaubt "stronger wins".
356        let w = EndpointProtection::PLAIN;
357        let r = EndpointProtection::new(ProtectionLevel::Sign);
358        let result = match_endpoints(&w, &r, true, true);
359        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Sign));
360    }
361}