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 and cache.
5//!
6//! [`PeerCapabilities`] is the snapshot of what a remote
7//! participant has communicated about its security capabilities
8//! (auth/crypto/access plugin class, accepted suites,
9//! offered protection level, validity window, etc.). In RC1
10//! this snapshot is populated from SPDP properties; here we
11//! only define the data model plus an in-memory cache
12//! for the runtime.
13//!
14//! The cache is deliberately a lean [`alloc::collections::BTreeMap`]
15//! wrapper: peers come and go, lookups are frequent, the typical
16//! peer setup has dozens to low thousands of entries
17//! — so no hash-overhead discussion.
18//!
19//! See `docs/architecture/08_heterogeneous_security.md` §3.2
20//! and §4.3 (upgrade path via `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/// Validity window of a peer identity (Unix seconds).
32///
33/// Minimal representation without a chrono dep — the actual
34/// time comparisons happen in the authentication plugin. This struct
35/// serves as a transparent pass-through container inside
36/// [`PeerCapabilities`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub struct Validity {
39    /// Not valid before this point in time (Unix seconds).
40    pub not_before: i64,
41    /// Not valid after this point in time (Unix seconds).
42    pub not_after: i64,
43}
44
45impl Validity {
46    /// Checks whether `now` lies within the validity window (inclusive).
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-relevant capabilities of a remote peer.
54///
55/// Populated from SPDP properties (auth/crypto/access plugin class,
56/// `zerodds.sec.supported_suites`, `zerodds.sec.offered_protection`)
57/// as well as from SEDP permissions tokens. Legacy peers without
58/// security properties land here with `auth_plugin_class=None` —
59/// no drop, the [`crate::PolicyEngine`] decides per
60/// domain rule whether legacy is accepted.
61///
62/// All fields are `Option`/`Vec`-based so that partial updates
63/// (upgrade path in §4.3 of the architecture doc) are cleanly possible.
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 without an 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 the peer would accept according to its SPDP announce.
74    pub supported_suites: Vec<SuiteHint>,
75    /// Protection level the peer **itself** offers.
76    pub offered_protection: ProtectionLevel,
77    /// `true` if the cert chain + OCSP were checked and ok — set by the
78    /// authentication plugin, not from SPDP.
79    pub has_valid_cert: bool,
80    /// Validity window from the permissions token.
81    pub validity_window: Option<Validity>,
82    /// Vendor identification (e.g. `"Cyclone DDS"`, `"Fast DDS"`)
83    /// for quirks.
84    pub vendor_hint: Option<String>,
85    /// Subject common name from the peer cert (e.g.
86    /// `"writer1.fast.example"`). Set by the authentication plugin after
87    /// a successful handshake; **not** propagated via SPDP.
88    /// Used for `<zerodds:peer_class><match cert_cn_pattern=...>`
89    ///.
90    pub cert_cn: Option<String>,
91    /// Delegation chain. Propagated by the edge or sub-gateway
92    /// via the SPDP property `zerodds.sec.delegation_chain`.
93    /// Validation against a delegation profile happens in
94    /// `peer_matches_class` (j-d). `None` = peer without a chain (= directly
95    /// authenticated peer or legacy).
96    pub delegation_chain: Option<DelegationChain>,
97}
98
99impl PeerCapabilities {
100    /// Merges non-empty fields from `other` into `self`. Empty fields
101    /// (`None`, `[]`) stay unchanged — so multiple
102    /// partial SPDP updates are idempotent and order-tolerant.
103    ///
104    /// Special rules:
105    /// * `offered_protection` is always taken over (monotonically increasing
106    ///   via [`ProtectionLevel::stronger`]) — a peer can upgrade its
107    ///   level but not silently downgrade it.
108    /// * `has_valid_cert=true` is sticky: once validated, it cannot
109    ///   fall back to `false` (cert rotation requires an
110    ///   explicit [`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/// One instance per participant; its lifetime matches the
146/// participant itself. Thread safety is provided by the caller via
147/// `Arc<Mutex<PeerCache>>` or `Arc<RwLock<PeerCache>>` — the cache
148/// itself is intentionally **not** thread-safe, because per tick
149/// usually only one writer/reader task touches it.
150#[derive(Debug, Default, Clone)]
151pub struct PeerCache {
152    inner: BTreeMap<PeerKey, PeerCapabilities>,
153}
154
155impl PeerCache {
156    /// Build an empty cache.
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Number of known peers.
163    #[must_use]
164    pub fn len(&self) -> usize {
165        self.inner.len()
166    }
167
168    /// `true` if no peer is known.
169    #[must_use]
170    pub fn is_empty(&self) -> bool {
171        self.inner.is_empty()
172    }
173
174    /// Full upsert. Overwrites an existing entry
175    /// with the new capabilities. For partial updates
176    /// use [`Self::update_partial`].
177    pub fn insert(&mut self, key: PeerKey, caps: PeerCapabilities) {
178        self.inner.insert(key, caps);
179    }
180
181    /// Reads the capabilities of a peer.
182    #[must_use]
183    pub fn get(&self, key: &PeerKey) -> Option<&PeerCapabilities> {
184        self.inner.get(key)
185    }
186
187    /// Merges new information into the existing entry.
188    /// If the peer is new → it is inserted.
189    ///
190    /// For the semantics see [`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    /// Removes a peer from the cache. Returns the old caps
199    /// if present — handy for logging at session end.
200    pub fn forget(&mut self, key: &PeerKey) -> Option<PeerCapabilities> {
201        self.inner.remove(key)
202    }
203
204    /// Iterator over all `(PeerKey, PeerCapabilities)` pairs.
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 must NOT reset the sticky-true
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 must not reduce offered_protection"
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!(base.has_valid_cert, "sticky: true must not become false");
323    }
324
325    #[test]
326    fn merge_supported_suites_replaces_when_update_has_any() {
327        let mut base = PeerCapabilities {
328            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
329            ..Default::default()
330        };
331        base.merge_update(&PeerCapabilities {
332            supported_suites: alloc::vec![SuiteHint::Aes256Gcm, SuiteHint::HmacSha256],
333            ..Default::default()
334        });
335        assert_eq!(
336            base.supported_suites,
337            alloc::vec![SuiteHint::Aes256Gcm, SuiteHint::HmacSha256]
338        );
339    }
340
341    // ---- PeerCache ----
342
343    #[test]
344    fn cache_new_is_empty() {
345        let c = PeerCache::new();
346        assert!(c.is_empty());
347        assert_eq!(c.len(), 0);
348    }
349
350    #[test]
351    fn cache_insert_and_get() {
352        let mut c = PeerCache::new();
353        let key: PeerKey = [1; 12];
354        c.insert(key, caps_secure());
355        assert_eq!(c.len(), 1);
356        assert_eq!(c.get(&key).unwrap(), &caps_secure());
357    }
358
359    #[test]
360    fn cache_insert_overwrites() {
361        let mut c = PeerCache::new();
362        let key: PeerKey = [2; 12];
363        c.insert(key, PeerCapabilities::default());
364        c.insert(key, caps_secure());
365        assert_eq!(c.get(&key).unwrap(), &caps_secure());
366        assert_eq!(c.len(), 1);
367    }
368
369    #[test]
370    fn cache_update_partial_inserts_when_missing() {
371        let mut c = PeerCache::new();
372        let key: PeerKey = [3; 12];
373        c.update_partial(key, &caps_secure());
374        assert_eq!(c.get(&key).unwrap(), &caps_secure());
375    }
376
377    #[test]
378    fn cache_update_partial_merges_into_existing() {
379        let mut c = PeerCache::new();
380        let key: PeerKey = [4; 12];
381        c.insert(
382            key,
383            PeerCapabilities {
384                auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
385                offered_protection: ProtectionLevel::Sign,
386                ..Default::default()
387            },
388        );
389        c.update_partial(
390            key,
391            &PeerCapabilities {
392                offered_protection: ProtectionLevel::Encrypt,
393                has_valid_cert: true,
394                ..Default::default()
395            },
396        );
397        let merged = c.get(&key).unwrap();
398        assert_eq!(merged.offered_protection, ProtectionLevel::Encrypt);
399        assert!(merged.has_valid_cert);
400        assert_eq!(
401            merged.auth_plugin_class.as_deref(),
402            Some("DDS:Auth:PKI-DH:1.2"),
403            "pre-existing fields are preserved"
404        );
405    }
406
407    #[test]
408    fn cache_forget_removes_and_returns_caps() {
409        let mut c = PeerCache::new();
410        let key: PeerKey = [5; 12];
411        c.insert(key, caps_secure());
412        let removed = c.forget(&key);
413        assert_eq!(removed, Some(caps_secure()));
414        assert!(c.get(&key).is_none());
415        assert!(c.is_empty());
416    }
417
418    #[test]
419    fn cache_forget_unknown_returns_none() {
420        let mut c = PeerCache::new();
421        assert!(c.forget(&[9; 12]).is_none());
422    }
423
424    #[test]
425    fn cache_iter_yields_all_entries() {
426        let mut c = PeerCache::new();
427        c.insert([1; 12], caps_secure());
428        c.insert([2; 12], PeerCapabilities::default());
429        let count = c.iter().count();
430        assert_eq!(count, 2);
431    }
432}