Skip to main content

zerodds_security_runtime/
caps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Peer-Capabilities und -Cache.
5//!
6//! [`PeerCapabilities`] ist der Snapshot dessen, was ein Remote-
7//! Participant ueber seine Security-Faehigkeiten kommuniziert hat
8//! (Auth-/Crypto-/Access-Plugin-Class, akzeptierte Suites,
9//! angebotenes Protection-Level, Validity-Window etc.). In RC1
10//! wird dieser Snapshot aus SPDP-Properties befuellt; hier
11//! definieren wir nur das Datenmodell plus einen in-memory-Cache
12//! fuer die Runtime.
13//!
14//! Der Cache ist bewusst ein schlanker [`alloc::collections::BTreeMap`]-
15//! Wrapper: Peers kommen und gehen, Lookups haeufig, das typische
16//! Peer-Setup hat dutzende bis niedrige Tausender von Eintraegen
17//! — also keine Hash-Overhead-Diskussion.
18//!
19//! Siehe `docs/architecture/08_heterogeneous_security.md` §3.2
20//! und §4.3 (Upgrade-Pfad per `update_partial`).
21
22use alloc::collections::BTreeMap;
23use alloc::string::String;
24use alloc::vec::Vec;
25
26use zerodds_security_pki::DelegationChain;
27
28use crate::policy::{ProtectionLevel, SuiteHint};
29use crate::shared::PeerKey;
30
31/// Gueltigkeitsfenster einer Peer-Identity (Unix-Seconds).
32///
33/// Minimal-Repraesentation ohne chrono-Dep — die konkreten
34/// Zeitvergleiche passieren im Authentication-Plugin. Diese Struct
35/// dient als transparenter Durchreich-Container in den
36/// [`PeerCapabilities`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub struct Validity {
39    /// Nicht gueltig vor diesem Zeitpunkt (Unix-Seconds).
40    pub not_before: i64,
41    /// Nicht gueltig nach diesem Zeitpunkt (Unix-Seconds).
42    pub not_after: i64,
43}
44
45impl Validity {
46    /// Prueft, ob `now` im Validity-Fenster liegt (inklusiv).
47    #[must_use]
48    pub const fn contains(&self, now: i64) -> bool {
49        now >= self.not_before && now <= self.not_after
50    }
51}
52
53/// Security-relevante Capabilities eines Remote-Peers.
54///
55/// Wird aus SPDP-Properties (Auth-/Crypto-/Access-Plugin-Class,
56/// `zerodds.sec.supported_suites`, `zerodds.sec.offered_protection`)
57/// sowie aus SEDP-Permissions-Tokens befuellt. Legacy-Peers ohne
58/// Security-Properties landen mit `auth_plugin_class=None` hier —
59/// kein Drop, die [`crate::PolicyEngine`] entscheidet pro
60/// Domain-Rule, ob Legacy akzeptiert wird.
61///
62/// Alle Felder sind `Option`-/`Vec`-basiert, damit Partial-Updates
63/// (Upgrade-Pfad in §4.3 der Architektur-Doc) sauber moeglich sind.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct PeerCapabilities {
66    /// `DDS:Auth:PKI-DH:1.2` (Spec 1.2 §10.3.2.1) etc. `None` = Legacy-
67    /// Peer ohne Auth-Plugin.
68    pub auth_plugin_class: Option<String>,
69    /// `DDS:Crypto:AES-GCM-GMAC:1.2` (Spec 1.2 §10.5) etc.
70    pub crypto_plugin_class: Option<String>,
71    /// `DDS:Access:Permissions:1.2` (Spec 1.2 §10.4) etc.
72    pub access_plugin_class: Option<String>,
73    /// Suites, die der Peer laut SPDP-Annonce akzeptieren wuerde.
74    pub supported_suites: Vec<SuiteHint>,
75    /// Protection-Level, das der Peer **selbst** anbietet.
76    pub offered_protection: ProtectionLevel,
77    /// `true` wenn Cert-Chain + OCSP geprueft und ok — wird vom
78    /// Authentication-Plugin gesetzt, nicht aus SPDP.
79    pub has_valid_cert: bool,
80    /// Validity-Window aus dem Permissions-Token.
81    pub validity_window: Option<Validity>,
82    /// Vendor-Identifikation (z.B. `"Cyclone DDS"`, `"Fast DDS"`)
83    /// fuer Quirks.
84    pub vendor_hint: Option<String>,
85    /// Subject-Common-Name aus dem Peer-Cert (z.B.
86    /// `"writer1.fast.example"`). Wird vom Authentication-Plugin nach
87    /// erfolgreichem Handshake gesetzt; **nicht** via SPDP propagiert.
88    /// Genutzt fuer `<zerodds:peer_class><match cert_cn_pattern=...>`
89    ///.
90    pub cert_cn: Option<String>,
91    /// Delegation-Chain. Wird vom Edge- oder Sub-Gateway
92    /// via SPDP-Property `zerodds.sec.delegation_chain` propagiert.
93    /// Validation gegen ein Delegation-Profile passiert in
94    /// `peer_matches_class` (j-d). `None` = Peer ohne Chain (= direkt
95    /// authentifizierter Peer oder Legacy).
96    pub delegation_chain: Option<DelegationChain>,
97}
98
99impl PeerCapabilities {
100    /// Mischt nicht-leere Felder aus `other` in `self`. Leere Felder
101    /// (`None`, `[]`) bleiben unveraendert — damit sind mehrere
102    /// partielle SPDP-Updates idempotent und reihenfolge-tolerant.
103    ///
104    /// Sonderregeln:
105    /// * `offered_protection` wird immer uebernommen (monoton steigend
106    ///   via [`ProtectionLevel::stronger`]) — ein Peer kann sein
107    ///   Level upgraden, aber nicht still herunterstufen.
108    /// * `has_valid_cert=true` ist sticky: einmal validiert, kann es
109    ///   nicht zu `false` zurueckfallen (Cert-Rotation erfordert
110    ///   explizites [`PeerCache::forget`]).
111    pub fn merge_update(&mut self, other: &PeerCapabilities) {
112        if other.auth_plugin_class.is_some() {
113            self.auth_plugin_class = other.auth_plugin_class.clone();
114        }
115        if other.crypto_plugin_class.is_some() {
116            self.crypto_plugin_class = other.crypto_plugin_class.clone();
117        }
118        if other.access_plugin_class.is_some() {
119            self.access_plugin_class = other.access_plugin_class.clone();
120        }
121        if !other.supported_suites.is_empty() {
122            self.supported_suites = other.supported_suites.clone();
123        }
124        self.offered_protection = self.offered_protection.stronger(other.offered_protection);
125        if other.has_valid_cert {
126            self.has_valid_cert = true;
127        }
128        if other.validity_window.is_some() {
129            self.validity_window = other.validity_window;
130        }
131        if other.vendor_hint.is_some() {
132            self.vendor_hint = other.vendor_hint.clone();
133        }
134        if other.cert_cn.is_some() {
135            self.cert_cn = other.cert_cn.clone();
136        }
137        if other.delegation_chain.is_some() {
138            self.delegation_chain = other.delegation_chain.clone();
139        }
140    }
141}
142
143/// In-memory-Cache `PeerKey → PeerCapabilities`.
144///
145/// Eine Instanz pro Participant; gelebte Lebensdauer entspricht dem
146/// Participant selbst. Thread-Safety liefert der Aufrufer via
147/// `Arc<Mutex<PeerCache>>` oder `Arc<RwLock<PeerCache>>` — der Cache
148/// selbst ist intentional **nicht** thread-safe, weil pro-Tick
149/// meist nur eine Writer-/Reader-Task ihn anfasst.
150#[derive(Debug, Default, Clone)]
151pub struct PeerCache {
152    inner: BTreeMap<PeerKey, PeerCapabilities>,
153}
154
155impl PeerCache {
156    /// Leeren Cache bauen.
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Anzahl bekannter Peers.
163    #[must_use]
164    pub fn len(&self) -> usize {
165        self.inner.len()
166    }
167
168    /// `true` wenn kein Peer bekannt.
169    #[must_use]
170    pub fn is_empty(&self) -> bool {
171        self.inner.is_empty()
172    }
173
174    /// Vollstaendiger Upsert. Ueberschreibt einen vorhandenen Eintrag
175    /// mit den neuen Capabilities. Fuer Teil-Updates
176    /// [`Self::update_partial`] nehmen.
177    pub fn insert(&mut self, key: PeerKey, caps: PeerCapabilities) {
178        self.inner.insert(key, caps);
179    }
180
181    /// Liest die Capabilities eines Peers.
182    #[must_use]
183    pub fn get(&self, key: &PeerKey) -> Option<&PeerCapabilities> {
184        self.inner.get(key)
185    }
186
187    /// Merged neue Informationen in den existierenden Eintrag.
188    /// Ist der Peer neu → wird er eingefuegt.
189    ///
190    /// Semantik siehe [`PeerCapabilities::merge_update`].
191    pub fn update_partial(&mut self, key: PeerKey, update: &PeerCapabilities) {
192        self.inner
193            .entry(key)
194            .and_modify(|existing| existing.merge_update(update))
195            .or_insert_with(|| update.clone());
196    }
197
198    /// Entfernt einen Peer aus dem Cache. Liefert die alten Caps
199    /// falls vorhanden — praktisch fuers Logging bei Session-End.
200    pub fn forget(&mut self, key: &PeerKey) -> Option<PeerCapabilities> {
201        self.inner.remove(key)
202    }
203
204    /// Iterator ueber alle `(PeerKey, PeerCapabilities)`-Paare.
205    pub fn iter(&self) -> impl Iterator<Item = (&PeerKey, &PeerCapabilities)> {
206        self.inner.iter()
207    }
208}
209
210// ============================================================================
211// Tests
212// ============================================================================
213
214#[cfg(test)]
215#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
216mod tests {
217    use super::*;
218    use alloc::string::ToString;
219
220    fn caps_secure() -> PeerCapabilities {
221        PeerCapabilities {
222            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
223            crypto_plugin_class: Some("DDS:Crypto:AES-GCM-GMAC:1.2".to_string()),
224            access_plugin_class: Some("DDS:Access:Permissions:1.2".to_string()),
225            supported_suites: alloc::vec![SuiteHint::Aes128Gcm, SuiteHint::Aes256Gcm],
226            offered_protection: ProtectionLevel::Encrypt,
227            has_valid_cert: true,
228            validity_window: Some(Validity {
229                not_before: 0,
230                not_after: 2_000_000_000,
231            }),
232            vendor_hint: Some("zerodds".to_string()),
233            cert_cn: Some("writer1.fast.example".to_string()),
234            delegation_chain: None,
235        }
236    }
237
238    // ---- Validity ----
239
240    #[test]
241    fn validity_contains_inside_window() {
242        let v = Validity {
243            not_before: 100,
244            not_after: 200,
245        };
246        assert!(v.contains(100));
247        assert!(v.contains(150));
248        assert!(v.contains(200));
249    }
250
251    #[test]
252    fn validity_rejects_outside_window() {
253        let v = Validity {
254            not_before: 100,
255            not_after: 200,
256        };
257        assert!(!v.contains(99));
258        assert!(!v.contains(201));
259    }
260
261    // ---- PeerCapabilities::merge_update ----
262
263    #[test]
264    fn merge_fills_empty_fields() {
265        let mut base = PeerCapabilities::default();
266        base.merge_update(&caps_secure());
267        assert_eq!(base, caps_secure());
268    }
269
270    #[test]
271    fn merge_preserves_existing_when_update_is_empty() {
272        let mut base = caps_secure();
273        let orig = base.clone();
274        base.merge_update(&PeerCapabilities::default());
275        // offered_protection default = None, stronger(Encrypt, None) = Encrypt
276        // has_valid_cert: default=false darf sticky-true NICHT zuruecksetzen
277        assert_eq!(base, orig);
278    }
279
280    #[test]
281    fn merge_upgrades_protection_monotonically() {
282        let mut base = PeerCapabilities {
283            offered_protection: ProtectionLevel::Sign,
284            ..Default::default()
285        };
286        let upgrade = PeerCapabilities {
287            offered_protection: ProtectionLevel::Encrypt,
288            ..Default::default()
289        };
290        base.merge_update(&upgrade);
291        assert_eq!(base.offered_protection, ProtectionLevel::Encrypt);
292    }
293
294    #[test]
295    fn merge_does_not_downgrade_protection() {
296        let mut base = PeerCapabilities {
297            offered_protection: ProtectionLevel::Encrypt,
298            ..Default::default()
299        };
300        let downgrade = PeerCapabilities {
301            offered_protection: ProtectionLevel::Sign,
302            ..Default::default()
303        };
304        base.merge_update(&downgrade);
305        assert_eq!(
306            base.offered_protection,
307            ProtectionLevel::Encrypt,
308            "downgrade darf offered_protection nicht reduzieren"
309        );
310    }
311
312    #[test]
313    fn merge_has_valid_cert_is_sticky_true() {
314        let mut base = PeerCapabilities {
315            has_valid_cert: true,
316            ..Default::default()
317        };
318        base.merge_update(&PeerCapabilities {
319            has_valid_cert: false,
320            ..Default::default()
321        });
322        assert!(
323            base.has_valid_cert,
324            "sticky: true darf nicht zu false werden"
325        );
326    }
327
328    #[test]
329    fn merge_supported_suites_replaces_when_update_has_any() {
330        let mut base = PeerCapabilities {
331            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
332            ..Default::default()
333        };
334        base.merge_update(&PeerCapabilities {
335            supported_suites: alloc::vec![SuiteHint::Aes256Gcm, SuiteHint::HmacSha256],
336            ..Default::default()
337        });
338        assert_eq!(
339            base.supported_suites,
340            alloc::vec![SuiteHint::Aes256Gcm, SuiteHint::HmacSha256]
341        );
342    }
343
344    // ---- PeerCache ----
345
346    #[test]
347    fn cache_new_is_empty() {
348        let c = PeerCache::new();
349        assert!(c.is_empty());
350        assert_eq!(c.len(), 0);
351    }
352
353    #[test]
354    fn cache_insert_and_get() {
355        let mut c = PeerCache::new();
356        let key: PeerKey = [1; 12];
357        c.insert(key, caps_secure());
358        assert_eq!(c.len(), 1);
359        assert_eq!(c.get(&key).unwrap(), &caps_secure());
360    }
361
362    #[test]
363    fn cache_insert_overwrites() {
364        let mut c = PeerCache::new();
365        let key: PeerKey = [2; 12];
366        c.insert(key, PeerCapabilities::default());
367        c.insert(key, caps_secure());
368        assert_eq!(c.get(&key).unwrap(), &caps_secure());
369        assert_eq!(c.len(), 1);
370    }
371
372    #[test]
373    fn cache_update_partial_inserts_when_missing() {
374        let mut c = PeerCache::new();
375        let key: PeerKey = [3; 12];
376        c.update_partial(key, &caps_secure());
377        assert_eq!(c.get(&key).unwrap(), &caps_secure());
378    }
379
380    #[test]
381    fn cache_update_partial_merges_into_existing() {
382        let mut c = PeerCache::new();
383        let key: PeerKey = [4; 12];
384        c.insert(
385            key,
386            PeerCapabilities {
387                auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
388                offered_protection: ProtectionLevel::Sign,
389                ..Default::default()
390            },
391        );
392        c.update_partial(
393            key,
394            &PeerCapabilities {
395                offered_protection: ProtectionLevel::Encrypt,
396                has_valid_cert: true,
397                ..Default::default()
398            },
399        );
400        let merged = c.get(&key).unwrap();
401        assert_eq!(merged.offered_protection, ProtectionLevel::Encrypt);
402        assert!(merged.has_valid_cert);
403        assert_eq!(
404            merged.auth_plugin_class.as_deref(),
405            Some("DDS:Auth:PKI-DH:1.2"),
406            "pre-existing Felder bleiben erhalten"
407        );
408    }
409
410    #[test]
411    fn cache_forget_removes_and_returns_caps() {
412        let mut c = PeerCache::new();
413        let key: PeerKey = [5; 12];
414        c.insert(key, caps_secure());
415        let removed = c.forget(&key);
416        assert_eq!(removed, Some(caps_secure()));
417        assert!(c.get(&key).is_none());
418        assert!(c.is_empty());
419    }
420
421    #[test]
422    fn cache_forget_unknown_returns_none() {
423        let mut c = PeerCache::new();
424        assert!(c.forget(&[9; 12]).is_none());
425    }
426
427    #[test]
428    fn cache_iter_yields_all_entries() {
429        let mut c = PeerCache::new();
430        c.insert([1; 12], caps_secure());
431        c.insert([2; 12], PeerCapabilities::default());
432        let count = c.iter().count();
433        assert_eq!(count, 2);
434    }
435}