Skip to main content

zerodds_security_runtime/
peer_class.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Peer-Class-Matching-Engine.
5//!
6//! Diese Schicht nimmt die aus dem Governance-XML geparsten
7//! [`PeerClass`]-Regeln und klassifiziert einen Remote-Peer (repraesentiert
8//! durch seine [`PeerCapabilities`]) in die passende Klasse. Die
9//! [`GovernancePolicyEngine`](crate::GovernancePolicyEngine) konsultiert
10//! das Resultat und gibt das entsprechende Protection-Level aus.
11//!
12//! # Matching-Regel
13//!
14//! * Iteration ueber `domain_rule.peer_classes` in XML-Reihenfolge —
15//!   **first match wins**.
16//! * Eine Peer-Klasse passt, wenn **alle** gesetzten
17//!   `match_criteria`-Felder erfuellt sind (UND-Verknuepfung):
18//!   * `auth_plugin_class` — exakter String-Vergleich. `""` matcht
19//!     `None` im Peer (Legacy-Peer ohne Plugin).
20//!   * `cert_cn_pattern` — Wildcard-Vergleich via
21//!     [`cn_pattern_match`](zerodds_security_permissions::cn_pattern_match).
22//!     Ein Peer ohne Cert-CN matcht nicht.
23//!   * `suite` — der Peer muss die Suite in seinen
24//!     `supported_suites` listen (CSV-String-Vergleich).
25//!   * `require_ocsp` — `PeerCapabilities::has_valid_cert` muss `true`
26//!     sein.
27//! * Leere `match_criteria` (alle Felder `None`/`false`) matcht
28//!   **jeden** Peer — das ist die Default-/Fallback-Klasse, die
29//!   typischerweise als letzter Eintrag im XML steht.
30//!
31//! # Spec-Referenz
32//!
33//! * `docs/architecture/08_heterogeneous_security.md` §5.
34//! * Plan-DoD §Stufe 8: "Governance-Beispiel mit 4 Peer-Classes
35//!   (Legacy/Fast/Secure/HA) wird korrekt geparst" — `resolve_peer_class`
36//!   ist die Runtime-Verifikation genau dieses Beispiels.
37
38use alloc::collections::BTreeMap;
39use alloc::string::String;
40
41use zerodds_security_permissions::{
42    DelegationProfile, PeerClass, ProtectionKind, ValidatedChain, cn_pattern_match, validate_chain,
43};
44use zerodds_security_pki::SignatureAlgorithm;
45
46use crate::caps::PeerCapabilities;
47use crate::policy::SuiteHint;
48
49/// Konvertiert einen [`SuiteHint`]-Wert in den String, den
50/// `<match suite="...">` erwartet. Muss konsistent mit
51/// [`crate::caps_wire`] sein.
52fn suite_hint_name(s: SuiteHint) -> &'static str {
53    match s {
54        SuiteHint::Aes128Gcm => "AES_128_GCM",
55        SuiteHint::Aes256Gcm => "AES_256_GCM",
56        SuiteHint::HmacSha256 => "HMAC_SHA256",
57    }
58}
59
60/// Prueft ob eine [`PeerClass`] zu den Peer-Capabilities passt.
61#[must_use]
62pub fn peer_matches_class(caps: &PeerCapabilities, class: &PeerClass) -> bool {
63    let m = &class.match_criteria;
64
65    if let Some(expected) = &m.auth_plugin_class {
66        match (expected.as_str(), caps.auth_plugin_class.as_deref()) {
67            // Leerer String im XML = Peer ohne Plugin (Legacy).
68            ("", None) => {}
69            // Sonst muss der Peer genau diesen Plugin-Class-String tragen.
70            (_, Some(actual)) if actual == expected => {}
71            _ => return false,
72        }
73    }
74
75    if let Some(pat) = &m.cert_cn_pattern {
76        match caps.cert_cn.as_deref() {
77            Some(cn) if cn_pattern_match(pat, cn) => {}
78            _ => return false,
79        }
80    }
81
82    if let Some(required) = &m.suite {
83        // Der Peer muss die Suite in `supported_suites` anbieten.
84        let offers_suite = caps
85            .supported_suites
86            .iter()
87            .any(|s| suite_hint_name(*s) == required.as_str());
88        if !offers_suite {
89            return false;
90        }
91    }
92
93    if m.require_ocsp && !caps.has_valid_cert {
94        return false;
95    }
96
97    // delegation_profile-Check passiert in `peer_matches_class_with_delegation`
98    // — der einfache `peer_matches_class` ignoriert das Feld bewusst, damit
99    // die RC1-Tests ihren bisherigen Pfad behalten und Caller, die
100    // Delegation aktiv nutzen wollen, explizit die erweiterte Funktion
101    // aufrufen muessen.
102    true
103}
104
105/// Erweiterte Variante von [`peer_matches_class`], die zusaetzlich
106/// `delegation_profile`-Referenzen aufloest.
107///
108/// Wenn `class.match_criteria.delegation_profile` gesetzt ist:
109/// 1. Der Peer MUSS eine [`DelegationChain`](zerodds_security_pki::DelegationChain)
110///    in seinen Capabilities haben.
111/// 2. Das referenzierte Profile MUSS in `profiles` existieren.
112/// 3. [`validate_chain`] gegen Profile + `now` MUSS Erfolg liefern
113///    (Trust-Anchor, Algorithm, Time, Sig, Depth, Scope).
114///
115/// `pubkey_resolver` wird an [`validate_chain`] durchgereicht und
116/// liefert PubKeys fuer Sub-Hop-Delegators (>1-Hop-Chains). Bei
117/// 1-Hop-Chain reicht der Trust-Anchor.
118///
119/// Wenn `delegation_profile` `None` ist, wird der reine
120/// [`peer_matches_class`]-Pfad genutzt — keine Chain-Erwartung.
121///
122/// Output: `Ok(Some(ValidatedChain))` wenn Delegation erfolgreich,
123/// `Ok(None)` wenn Class matched aber ohne Delegation-Path,
124/// `Err(())` wenn Delegation gefordert aber Validation fehlschlaegt.
125pub fn peer_matches_class_with_delegation<F>(
126    caps: &PeerCapabilities,
127    class: &PeerClass,
128    profiles: &BTreeMap<String, DelegationProfile>,
129    now: i64,
130    pubkey_resolver: F,
131) -> Result<Option<ValidatedChain>, &'static str>
132where
133    F: Fn(&[u8; 16]) -> Option<(alloc::vec::Vec<u8>, SignatureAlgorithm)>,
134{
135    if !peer_matches_class(caps, class) {
136        return Err("class match criteria failed");
137    }
138    let Some(profile_name) = &class.match_criteria.delegation_profile else {
139        // Klasse fordert keine Delegation — direkter Pfad ok.
140        return Ok(None);
141    };
142    let profile = profiles
143        .get(profile_name.as_str())
144        .ok_or("delegation_profile referenced but not in governance.delegation_profiles")?;
145    let chain = caps
146        .delegation_chain
147        .as_ref()
148        .ok_or("class requires delegation_profile but peer has no chain")?;
149    validate_chain(chain, profile, now, pubkey_resolver)
150        .map(Some)
151        .map_err(|_| "delegation chain failed validation")
152}
153
154/// Sucht die erste matchende Peer-Klasse fuer einen Peer. Gibt
155/// `None` zurueck wenn keine Klasse passt — der Caller faellt dann
156/// auf die Domain-Default-Protection zurueck.
157#[must_use]
158pub fn resolve_peer_class<'a>(
159    caps: &PeerCapabilities,
160    classes: &'a [PeerClass],
161) -> Option<&'a PeerClass> {
162    classes.iter().find(|c| peer_matches_class(caps, c))
163}
164
165/// Extrahiert das Protection-Level einer matchenden Peer-Class.
166/// `None` wenn keine Klasse matched.
167#[must_use]
168pub fn resolve_protection(
169    caps: &PeerCapabilities,
170    classes: &[PeerClass],
171) -> Option<ProtectionKind> {
172    resolve_peer_class(caps, classes).map(|c| c.protection)
173}
174
175/// Erlaubt-Liste-Check: darf ein Peer einer bestimmten Klasse auf
176/// dem gegebenen Interface kommunizieren?
177///
178/// `peer_class_filter` ist leer → jede Klasse ist erlaubt.
179/// Sonst muss der `class_name` in der Filterliste auftauchen.
180#[must_use]
181pub fn interface_accepts_class(class_name: &str, peer_class_filter: &[String]) -> bool {
182    peer_class_filter.is_empty() || peer_class_filter.iter().any(|f| f == class_name)
183}
184
185// ============================================================================
186// Tests
187// ============================================================================
188
189#[cfg(test)]
190#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
191mod tests {
192    use super::*;
193    use zerodds_security_permissions::{PeerClass, PeerClassMatch, ProtectionKind};
194
195    fn legacy_class() -> PeerClass {
196        PeerClass {
197            name: "legacy".into(),
198            protection: ProtectionKind::None,
199            match_criteria: PeerClassMatch {
200                auth_plugin_class: Some(String::new()),
201                ..Default::default()
202            },
203        }
204    }
205
206    fn fast_class() -> PeerClass {
207        PeerClass {
208            name: "fast".into(),
209            protection: ProtectionKind::Sign,
210            match_criteria: PeerClassMatch {
211                cert_cn_pattern: Some("*.fast.example".into()),
212                ..Default::default()
213            },
214        }
215    }
216
217    fn secure_class() -> PeerClass {
218        PeerClass {
219            name: "secure".into(),
220            protection: ProtectionKind::Encrypt,
221            match_criteria: PeerClassMatch {
222                auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
223                suite: Some("AES_128_GCM".into()),
224                ..Default::default()
225            },
226        }
227    }
228
229    fn ha_class() -> PeerClass {
230        PeerClass {
231            name: "highassurance".into(),
232            protection: ProtectionKind::Encrypt,
233            match_criteria: PeerClassMatch {
234                cert_cn_pattern: Some("*.ha.*".into()),
235                suite: Some("AES_256_GCM".into()),
236                require_ocsp: true,
237                ..Default::default()
238            },
239        }
240    }
241
242    fn legacy_caps() -> PeerCapabilities {
243        PeerCapabilities::default()
244    }
245
246    fn fast_caps() -> PeerCapabilities {
247        // Fast-Peer: hat Auth-Plugin (sonst wuerde legacy zuerst
248        // matchen wegen leerem auth_plugin_class=""), aber nutzt HMAC-
249        // only + hat einen Cert-CN im .fast.example-Namensraum.
250        PeerCapabilities {
251            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
252            cert_cn: Some("writer1.fast.example".into()),
253            supported_suites: alloc::vec![SuiteHint::HmacSha256],
254            ..Default::default()
255        }
256    }
257
258    fn secure_caps() -> PeerCapabilities {
259        PeerCapabilities {
260            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
261            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
262            ..Default::default()
263        }
264    }
265
266    fn ha_caps() -> PeerCapabilities {
267        PeerCapabilities {
268            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
269            cert_cn: Some("writer.ha.corp".into()),
270            supported_suites: alloc::vec![SuiteHint::Aes256Gcm],
271            has_valid_cert: true,
272            ..Default::default()
273        }
274    }
275
276    // ---- peer_matches_class ----
277
278    #[test]
279    fn legacy_caps_match_legacy_class() {
280        assert!(peer_matches_class(&legacy_caps(), &legacy_class()));
281    }
282
283    #[test]
284    fn fast_caps_match_fast_cn_pattern() {
285        assert!(peer_matches_class(&fast_caps(), &fast_class()));
286    }
287
288    #[test]
289    fn secure_caps_need_both_auth_and_suite() {
290        assert!(peer_matches_class(&secure_caps(), &secure_class()));
291
292        // Peer mit Auth aber ohne Suite → kein Match.
293        let only_auth = PeerCapabilities {
294            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
295            supported_suites: alloc::vec![],
296            ..Default::default()
297        };
298        assert!(!peer_matches_class(&only_auth, &secure_class()));
299
300        // Peer mit Suite aber falschem Plugin → kein Match.
301        let wrong_auth = PeerCapabilities {
302            auth_plugin_class: Some("DDS:Auth:Custom".into()),
303            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
304            ..Default::default()
305        };
306        assert!(!peer_matches_class(&wrong_auth, &secure_class()));
307    }
308
309    #[test]
310    fn ha_caps_need_ocsp() {
311        assert!(peer_matches_class(&ha_caps(), &ha_class()));
312
313        // Gleiche Caps, aber has_valid_cert=false → kein Match.
314        let no_ocsp = PeerCapabilities {
315            has_valid_cert: false,
316            ..ha_caps()
317        };
318        assert!(!peer_matches_class(&no_ocsp, &ha_class()));
319    }
320
321    #[test]
322    fn peer_without_cn_does_not_match_cn_pattern_class() {
323        assert!(!peer_matches_class(&legacy_caps(), &fast_class()));
324    }
325
326    #[test]
327    fn empty_match_criteria_matches_every_peer() {
328        let fallback = PeerClass {
329            name: "fallback".into(),
330            protection: ProtectionKind::Sign,
331            match_criteria: PeerClassMatch::default(),
332        };
333        assert!(peer_matches_class(&legacy_caps(), &fallback));
334        assert!(peer_matches_class(&fast_caps(), &fallback));
335        assert!(peer_matches_class(&secure_caps(), &fallback));
336    }
337
338    #[test]
339    fn legacy_class_rejects_peer_with_plugin() {
340        // auth_plugin_class="" matcht NUR Peers ohne Plugin.
341        let secured = secure_caps();
342        assert!(!peer_matches_class(&secured, &legacy_class()));
343    }
344
345    // ---- resolve_peer_class (first-match-wins) ----
346
347    #[test]
348    fn resolve_peer_class_first_match_wins() {
349        let classes = alloc::vec![legacy_class(), fast_class(), secure_class(), ha_class(),];
350
351        assert_eq!(
352            resolve_peer_class(&legacy_caps(), &classes).map(|c| c.name.as_str()),
353            Some("legacy")
354        );
355        assert_eq!(
356            resolve_peer_class(&fast_caps(), &classes).map(|c| c.name.as_str()),
357            Some("fast")
358        );
359        assert_eq!(
360            resolve_peer_class(&secure_caps(), &classes).map(|c| c.name.as_str()),
361            Some("secure")
362        );
363        assert_eq!(
364            resolve_peer_class(&ha_caps(), &classes).map(|c| c.name.as_str()),
365            Some("highassurance")
366        );
367    }
368
369    #[test]
370    fn resolve_peer_class_no_match_returns_none() {
371        // Caps mit ausschliesslich cert_cn der weder fast noch ha
372        // matcht, kein Plugin, keine Suite.
373        let caps = PeerCapabilities {
374            cert_cn: Some("misc.corp".into()),
375            ..Default::default()
376        };
377        let classes = alloc::vec![fast_class(), secure_class(), ha_class()];
378        assert!(resolve_peer_class(&caps, &classes).is_none());
379    }
380
381    #[test]
382    fn resolve_protection_maps_to_class_protection() {
383        let classes = alloc::vec![legacy_class(), secure_class()];
384        assert_eq!(
385            resolve_protection(&legacy_caps(), &classes),
386            Some(ProtectionKind::None)
387        );
388        assert_eq!(
389            resolve_protection(&secure_caps(), &classes),
390            Some(ProtectionKind::Encrypt)
391        );
392    }
393
394    // ---- interface_accepts_class ----
395
396    #[test]
397    fn interface_accepts_any_class_when_filter_empty() {
398        assert!(interface_accepts_class("legacy", &[]));
399        assert!(interface_accepts_class("highassurance", &[]));
400    }
401
402    #[test]
403    fn interface_accepts_only_listed_classes() {
404        let filter = alloc::vec!["secure".into(), "highassurance".into()];
405        assert!(interface_accepts_class("secure", &filter));
406        assert!(interface_accepts_class("highassurance", &filter));
407        assert!(!interface_accepts_class("legacy", &filter));
408        assert!(!interface_accepts_class("fast", &filter));
409    }
410
411    // ---- RC1: peer_matches_class_with_delegation ----
412
413    use zerodds_security_permissions::{DelegationProfile, TrustAnchor};
414    use zerodds_security_pki::{DelegationChain, DelegationLink};
415
416    fn make_chain_signed_by(
417        gw: [u8; 16],
418        edge: [u8; 16],
419        topics: &[&str],
420    ) -> (DelegationChain, alloc::vec::Vec<u8>) {
421        use ring::rand::SystemRandom;
422        use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
423        let rng = SystemRandom::new();
424        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
425        let sk = pkcs8.as_ref().to_vec();
426        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &sk, &rng).unwrap();
427        let pk = kp.public_key().as_ref().to_vec();
428        let mut link = DelegationLink::new(
429            gw,
430            edge,
431            topics.iter().map(|s| s.to_string()).collect(),
432            alloc::vec![],
433            1_000,
434            9_000,
435            SignatureAlgorithm::EcdsaP256,
436        )
437        .unwrap();
438        link.sign(&sk).unwrap();
439        (DelegationChain::new(gw, alloc::vec![link]).unwrap(), pk)
440    }
441
442    fn delegated_class(profile_name: &str) -> PeerClass {
443        PeerClass {
444            name: "delegated-edge".into(),
445            protection: ProtectionKind::Encrypt,
446            match_criteria: PeerClassMatch {
447                auth_plugin_class: Some(String::new()), // edge ohne eigenes plugin
448                delegation_profile: Some(profile_name.into()),
449                ..Default::default()
450            },
451        }
452    }
453
454    #[test]
455    fn delegation_class_match_with_valid_chain() {
456        let gw = [0xAA; 16];
457        let edge = [0xBB; 16];
458        let (chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
459
460        let mut profiles = BTreeMap::new();
461        profiles.insert(
462            "vehicle-edges".to_string(),
463            DelegationProfile::default_with_anchor(
464                "vehicle-edges".to_string(),
465                TrustAnchor {
466                    subject_guid: gw,
467                    verify_public_key: pk,
468                    algorithm: SignatureAlgorithm::EcdsaP256,
469                },
470            ),
471        );
472
473        let caps = PeerCapabilities {
474            delegation_chain: Some(chain),
475            ..Default::default()
476        };
477        let class = delegated_class("vehicle-edges");
478        let result = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None);
479        let validated = result.expect("must validate").expect("chain produced");
480        assert_eq!(validated.edge_guid, edge);
481        assert_eq!(validated.chain_depth, 1);
482    }
483
484    #[test]
485    fn delegation_class_rejects_peer_without_chain() {
486        let gw = [0xAA; 16];
487        let edge = [0xBB; 16];
488        let (_chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
489
490        let mut profiles = BTreeMap::new();
491        profiles.insert(
492            "vehicle-edges".to_string(),
493            DelegationProfile::default_with_anchor(
494                "vehicle-edges".to_string(),
495                TrustAnchor {
496                    subject_guid: gw,
497                    verify_public_key: pk,
498                    algorithm: SignatureAlgorithm::EcdsaP256,
499                },
500            ),
501        );
502
503        let caps = PeerCapabilities {
504            delegation_chain: None,
505            ..Default::default()
506        };
507        let class = delegated_class("vehicle-edges");
508        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
509            .expect_err("must fail");
510        assert!(err.contains("no chain"));
511    }
512
513    #[test]
514    fn delegation_class_rejects_unknown_profile_reference() {
515        let caps = PeerCapabilities::default();
516        let class = delegated_class("nonexistent-profile");
517        let profiles: BTreeMap<String, DelegationProfile> = BTreeMap::new();
518        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
519            .expect_err("must fail");
520        assert!(err.contains("not in governance"));
521    }
522
523    #[test]
524    fn delegation_class_rejects_invalid_chain() {
525        let gw = [0xAA; 16];
526        let edge = [0xBB; 16];
527        let (chain, _pk_correct) = make_chain_signed_by(gw, edge, &["sensor/*"]);
528        // Anchor mit falschem PubKey → Validation fehlschlaegt.
529        let mut profiles = BTreeMap::new();
530        profiles.insert(
531            "vehicle-edges".to_string(),
532            DelegationProfile::default_with_anchor(
533                "vehicle-edges".to_string(),
534                TrustAnchor {
535                    subject_guid: gw,
536                    verify_public_key: alloc::vec![0u8; 65],
537                    algorithm: SignatureAlgorithm::EcdsaP256,
538                },
539            ),
540        );
541
542        let caps = PeerCapabilities {
543            delegation_chain: Some(chain),
544            ..Default::default()
545        };
546        let class = delegated_class("vehicle-edges");
547        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
548            .expect_err("must fail");
549        assert!(err.contains("validation"));
550    }
551
552    #[test]
553    fn class_without_delegation_profile_returns_ok_none() {
554        // Direkt-Auth-Pfad: Klasse ohne delegation_profile.
555        let caps = PeerCapabilities {
556            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
557            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
558            ..Default::default()
559        };
560        let class = secure_class();
561        let profiles: BTreeMap<String, DelegationProfile> = BTreeMap::new();
562        let result = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None);
563        assert!(matches!(result, Ok(None)));
564    }
565
566    #[test]
567    fn delegation_class_rejects_chain_outside_time_window() {
568        let gw = [0xAA; 16];
569        let edge = [0xBB; 16];
570        let (chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
571
572        let mut profiles = BTreeMap::new();
573        profiles.insert(
574            "vehicle-edges".to_string(),
575            DelegationProfile::default_with_anchor(
576                "vehicle-edges".to_string(),
577                TrustAnchor {
578                    subject_guid: gw,
579                    verify_public_key: pk,
580                    algorithm: SignatureAlgorithm::EcdsaP256,
581                },
582            ),
583        );
584
585        let caps = PeerCapabilities {
586            delegation_chain: Some(chain),
587            ..Default::default()
588        };
589        let class = delegated_class("vehicle-edges");
590        // now = 50_000 ist weit nach not_after=9_000
591        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 50_000, |_| None)
592            .expect_err("must fail");
593        assert!(err.contains("validation"));
594    }
595}