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//! This layer takes the [`PeerClass`] rules parsed from the
7//! governance XML and classifies a remote peer (represented
8//! by its [`PeerCapabilities`]) into the matching class. The
9//! [`GovernancePolicyEngine`](crate::GovernancePolicyEngine) consults
10//! the result and emits the corresponding protection level.
11//!
12//! # Matching rule
13//!
14//! * Iteration over `domain_rule.peer_classes` in XML order —
15//!   **first match wins**.
16//! * A peer class matches if **all** set
17//!   `match_criteria` fields are satisfied (AND combination):
18//!   * `auth_plugin_class` — exact string comparison. `""` matches
19//!     `None` in the peer (legacy peer without a plugin).
20//!   * `cert_cn_pattern` — wildcard comparison via
21//!     [`cn_pattern_match`](zerodds_security_permissions::cn_pattern_match).
22//!     A peer without a cert CN does not match.
23//!   * `suite` — the peer must list the suite in its
24//!     `supported_suites` (CSV string comparison).
25//!   * `require_ocsp` — `PeerCapabilities::has_valid_cert` must be `true`.
26//! * Empty `match_criteria` (all fields `None`/`false`) matches
27//!   **every** peer — this is the default/fallback class, which
28//!   typically is the last entry in the XML.
29//!
30//! # Spec reference
31//!
32//! * `docs/architecture/08_heterogeneous_security.md` §5.
33//! * Plan DoD §stage 8: "governance example with 4 peer classes
34//!   (legacy/fast/secure/HA) is parsed correctly" — `resolve_peer_class`
35//!   is the runtime verification of exactly this example.
36
37use alloc::collections::BTreeMap;
38use alloc::string::String;
39
40use zerodds_security_permissions::{
41    DelegationProfile, PeerClass, ProtectionKind, ValidatedChain, cn_pattern_match, validate_chain,
42};
43use zerodds_security_pki::SignatureAlgorithm;
44
45use crate::caps::PeerCapabilities;
46use crate::policy::SuiteHint;
47
48/// Converts a [`SuiteHint`] value into the string that
49/// `<match suite="...">` expects. Must be consistent with
50/// [`crate::caps_wire`].
51fn suite_hint_name(s: SuiteHint) -> &'static str {
52    match s {
53        SuiteHint::Aes128Gcm => "AES_128_GCM",
54        SuiteHint::Aes256Gcm => "AES_256_GCM",
55        SuiteHint::HmacSha256 => "HMAC_SHA256",
56    }
57}
58
59/// Checks whether a [`PeerClass`] matches the peer capabilities.
60#[must_use]
61pub fn peer_matches_class(caps: &PeerCapabilities, class: &PeerClass) -> bool {
62    let m = &class.match_criteria;
63
64    if let Some(expected) = &m.auth_plugin_class {
65        match (expected.as_str(), caps.auth_plugin_class.as_deref()) {
66            // Empty string in the XML = peer without a plugin (legacy).
67            ("", None) => {}
68            // Otherwise the peer must carry exactly this plugin-class string.
69            (_, Some(actual)) if actual == expected => {}
70            _ => return false,
71        }
72    }
73
74    if let Some(pat) = &m.cert_cn_pattern {
75        match caps.cert_cn.as_deref() {
76            Some(cn) if cn_pattern_match(pat, cn) => {}
77            _ => return false,
78        }
79    }
80
81    if let Some(required) = &m.suite {
82        // The peer must offer the suite in `supported_suites`.
83        let offers_suite = caps
84            .supported_suites
85            .iter()
86            .any(|s| suite_hint_name(*s) == required.as_str());
87        if !offers_suite {
88            return false;
89        }
90    }
91
92    if m.require_ocsp && !caps.has_valid_cert {
93        return false;
94    }
95
96    // The delegation_profile check happens in `peer_matches_class_with_delegation`
97    // — the plain `peer_matches_class` deliberately ignores the field, so
98    // the RC1 tests keep their existing path and callers that want to
99    // actively use delegation must explicitly call the extended function.
100    true
101}
102
103/// Extended variant of [`peer_matches_class`] that additionally
104/// resolves `delegation_profile` references.
105///
106/// If `class.match_criteria.delegation_profile` is set:
107/// 1. The peer MUST have a [`DelegationChain`](zerodds_security_pki::DelegationChain)
108///    in its capabilities.
109/// 2. The referenced profile MUST exist in `profiles`.
110/// 3. [`validate_chain`] against the profile + `now` MUST succeed
111///    (trust anchor, algorithm, time, sig, depth, scope).
112///
113/// `pubkey_resolver` is passed through to [`validate_chain`] and
114/// provides pubkeys for sub-hop delegators (>1-hop chains). For a
115/// 1-hop chain the trust anchor suffices.
116///
117/// If `delegation_profile` is `None`, the plain
118/// [`peer_matches_class`] path is used — no chain expectation.
119///
120/// Output: `Ok(Some(ValidatedChain))` if delegation succeeded,
121/// `Ok(None)` if the class matched but without a delegation path,
122/// `Err(())` if delegation was required but validation fails.
123pub fn peer_matches_class_with_delegation<F>(
124    caps: &PeerCapabilities,
125    class: &PeerClass,
126    profiles: &BTreeMap<String, DelegationProfile>,
127    now: i64,
128    pubkey_resolver: F,
129) -> Result<Option<ValidatedChain>, &'static str>
130where
131    F: Fn(&[u8; 16]) -> Option<(alloc::vec::Vec<u8>, SignatureAlgorithm)>,
132{
133    if !peer_matches_class(caps, class) {
134        return Err("class match criteria failed");
135    }
136    let Some(profile_name) = &class.match_criteria.delegation_profile else {
137        // The class requires no delegation — direct path ok.
138        return Ok(None);
139    };
140    let profile = profiles
141        .get(profile_name.as_str())
142        .ok_or("delegation_profile referenced but not in governance.delegation_profiles")?;
143    let chain = caps
144        .delegation_chain
145        .as_ref()
146        .ok_or("class requires delegation_profile but peer has no chain")?;
147    validate_chain(chain, profile, now, pubkey_resolver)
148        .map(Some)
149        .map_err(|_| "delegation chain failed validation")
150}
151
152/// Finds the first matching peer class for a peer. Returns
153/// `None` if no class matches — the caller then falls back
154/// to the domain default protection.
155#[must_use]
156pub fn resolve_peer_class<'a>(
157    caps: &PeerCapabilities,
158    classes: &'a [PeerClass],
159) -> Option<&'a PeerClass> {
160    classes.iter().find(|c| peer_matches_class(caps, c))
161}
162
163/// Extracts the protection level of a matching peer class.
164/// `None` if no class matched.
165#[must_use]
166pub fn resolve_protection(
167    caps: &PeerCapabilities,
168    classes: &[PeerClass],
169) -> Option<ProtectionKind> {
170    resolve_peer_class(caps, classes).map(|c| c.protection)
171}
172
173/// Allow-list check: may a peer of a given class communicate on
174/// the given interface?
175///
176/// `peer_class_filter` is empty → every class is allowed.
177/// Otherwise the `class_name` must appear in the filter list.
178#[must_use]
179pub fn interface_accepts_class(class_name: &str, peer_class_filter: &[String]) -> bool {
180    peer_class_filter.is_empty() || peer_class_filter.iter().any(|f| f == class_name)
181}
182
183// ============================================================================
184// Tests
185// ============================================================================
186
187#[cfg(test)]
188#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
189mod tests {
190    use super::*;
191    use zerodds_security_permissions::{PeerClass, PeerClassMatch, ProtectionKind};
192
193    fn legacy_class() -> PeerClass {
194        PeerClass {
195            name: "legacy".into(),
196            protection: ProtectionKind::None,
197            match_criteria: PeerClassMatch {
198                auth_plugin_class: Some(String::new()),
199                ..Default::default()
200            },
201        }
202    }
203
204    fn fast_class() -> PeerClass {
205        PeerClass {
206            name: "fast".into(),
207            protection: ProtectionKind::Sign,
208            match_criteria: PeerClassMatch {
209                cert_cn_pattern: Some("*.fast.example".into()),
210                ..Default::default()
211            },
212        }
213    }
214
215    fn secure_class() -> PeerClass {
216        PeerClass {
217            name: "secure".into(),
218            protection: ProtectionKind::Encrypt,
219            match_criteria: PeerClassMatch {
220                auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
221                suite: Some("AES_128_GCM".into()),
222                ..Default::default()
223            },
224        }
225    }
226
227    fn ha_class() -> PeerClass {
228        PeerClass {
229            name: "highassurance".into(),
230            protection: ProtectionKind::Encrypt,
231            match_criteria: PeerClassMatch {
232                cert_cn_pattern: Some("*.ha.*".into()),
233                suite: Some("AES_256_GCM".into()),
234                require_ocsp: true,
235                ..Default::default()
236            },
237        }
238    }
239
240    fn legacy_caps() -> PeerCapabilities {
241        PeerCapabilities::default()
242    }
243
244    fn fast_caps() -> PeerCapabilities {
245        // Fast peer: has an auth plugin (otherwise legacy would match
246        // first due to the empty auth_plugin_class=""), but uses HMAC
247        // only + has a cert CN in the .fast.example namespace.
248        PeerCapabilities {
249            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
250            cert_cn: Some("writer1.fast.example".into()),
251            supported_suites: alloc::vec![SuiteHint::HmacSha256],
252            ..Default::default()
253        }
254    }
255
256    fn secure_caps() -> PeerCapabilities {
257        PeerCapabilities {
258            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
259            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
260            ..Default::default()
261        }
262    }
263
264    fn ha_caps() -> PeerCapabilities {
265        PeerCapabilities {
266            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
267            cert_cn: Some("writer.ha.corp".into()),
268            supported_suites: alloc::vec![SuiteHint::Aes256Gcm],
269            has_valid_cert: true,
270            ..Default::default()
271        }
272    }
273
274    // ---- peer_matches_class ----
275
276    #[test]
277    fn legacy_caps_match_legacy_class() {
278        assert!(peer_matches_class(&legacy_caps(), &legacy_class()));
279    }
280
281    #[test]
282    fn fast_caps_match_fast_cn_pattern() {
283        assert!(peer_matches_class(&fast_caps(), &fast_class()));
284    }
285
286    #[test]
287    fn secure_caps_need_both_auth_and_suite() {
288        assert!(peer_matches_class(&secure_caps(), &secure_class()));
289
290        // Peer with auth but without a suite → no match.
291        let only_auth = PeerCapabilities {
292            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
293            supported_suites: alloc::vec![],
294            ..Default::default()
295        };
296        assert!(!peer_matches_class(&only_auth, &secure_class()));
297
298        // Peer with a suite but the wrong plugin → no match.
299        let wrong_auth = PeerCapabilities {
300            auth_plugin_class: Some("DDS:Auth:Custom".into()),
301            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
302            ..Default::default()
303        };
304        assert!(!peer_matches_class(&wrong_auth, &secure_class()));
305    }
306
307    #[test]
308    fn ha_caps_need_ocsp() {
309        assert!(peer_matches_class(&ha_caps(), &ha_class()));
310
311        // Same caps but has_valid_cert=false → no match.
312        let no_ocsp = PeerCapabilities {
313            has_valid_cert: false,
314            ..ha_caps()
315        };
316        assert!(!peer_matches_class(&no_ocsp, &ha_class()));
317    }
318
319    #[test]
320    fn peer_without_cn_does_not_match_cn_pattern_class() {
321        assert!(!peer_matches_class(&legacy_caps(), &fast_class()));
322    }
323
324    #[test]
325    fn empty_match_criteria_matches_every_peer() {
326        let fallback = PeerClass {
327            name: "fallback".into(),
328            protection: ProtectionKind::Sign,
329            match_criteria: PeerClassMatch::default(),
330        };
331        assert!(peer_matches_class(&legacy_caps(), &fallback));
332        assert!(peer_matches_class(&fast_caps(), &fallback));
333        assert!(peer_matches_class(&secure_caps(), &fallback));
334    }
335
336    #[test]
337    fn legacy_class_rejects_peer_with_plugin() {
338        // auth_plugin_class="" matches ONLY peers without a plugin.
339        let secured = secure_caps();
340        assert!(!peer_matches_class(&secured, &legacy_class()));
341    }
342
343    // ---- resolve_peer_class (first-match-wins) ----
344
345    #[test]
346    fn resolve_peer_class_first_match_wins() {
347        let classes = alloc::vec![legacy_class(), fast_class(), secure_class(), ha_class(),];
348
349        assert_eq!(
350            resolve_peer_class(&legacy_caps(), &classes).map(|c| c.name.as_str()),
351            Some("legacy")
352        );
353        assert_eq!(
354            resolve_peer_class(&fast_caps(), &classes).map(|c| c.name.as_str()),
355            Some("fast")
356        );
357        assert_eq!(
358            resolve_peer_class(&secure_caps(), &classes).map(|c| c.name.as_str()),
359            Some("secure")
360        );
361        assert_eq!(
362            resolve_peer_class(&ha_caps(), &classes).map(|c| c.name.as_str()),
363            Some("highassurance")
364        );
365    }
366
367    #[test]
368    fn resolve_peer_class_no_match_returns_none() {
369        // Caps with only a cert_cn that matches neither fast nor ha,
370        // no plugin, no suite.
371        let caps = PeerCapabilities {
372            cert_cn: Some("misc.corp".into()),
373            ..Default::default()
374        };
375        let classes = alloc::vec![fast_class(), secure_class(), ha_class()];
376        assert!(resolve_peer_class(&caps, &classes).is_none());
377    }
378
379    #[test]
380    fn resolve_protection_maps_to_class_protection() {
381        let classes = alloc::vec![legacy_class(), secure_class()];
382        assert_eq!(
383            resolve_protection(&legacy_caps(), &classes),
384            Some(ProtectionKind::None)
385        );
386        assert_eq!(
387            resolve_protection(&secure_caps(), &classes),
388            Some(ProtectionKind::Encrypt)
389        );
390    }
391
392    // ---- interface_accepts_class ----
393
394    #[test]
395    fn interface_accepts_any_class_when_filter_empty() {
396        assert!(interface_accepts_class("legacy", &[]));
397        assert!(interface_accepts_class("highassurance", &[]));
398    }
399
400    #[test]
401    fn interface_accepts_only_listed_classes() {
402        let filter = alloc::vec!["secure".into(), "highassurance".into()];
403        assert!(interface_accepts_class("secure", &filter));
404        assert!(interface_accepts_class("highassurance", &filter));
405        assert!(!interface_accepts_class("legacy", &filter));
406        assert!(!interface_accepts_class("fast", &filter));
407    }
408
409    // ---- RC1: peer_matches_class_with_delegation ----
410
411    use zerodds_security_permissions::{DelegationProfile, TrustAnchor};
412    use zerodds_security_pki::{DelegationChain, DelegationLink};
413
414    fn make_chain_signed_by(
415        gw: [u8; 16],
416        edge: [u8; 16],
417        topics: &[&str],
418    ) -> (DelegationChain, alloc::vec::Vec<u8>) {
419        use ring::rand::SystemRandom;
420        use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
421        let rng = SystemRandom::new();
422        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
423        let sk = pkcs8.as_ref().to_vec();
424        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &sk, &rng).unwrap();
425        let pk = kp.public_key().as_ref().to_vec();
426        let mut link = DelegationLink::new(
427            gw,
428            edge,
429            topics.iter().map(|s| s.to_string()).collect(),
430            alloc::vec![],
431            1_000,
432            9_000,
433            SignatureAlgorithm::EcdsaP256,
434        )
435        .unwrap();
436        link.sign(&sk).unwrap();
437        (DelegationChain::new(gw, alloc::vec![link]).unwrap(), pk)
438    }
439
440    fn delegated_class(profile_name: &str) -> PeerClass {
441        PeerClass {
442            name: "delegated-edge".into(),
443            protection: ProtectionKind::Encrypt,
444            match_criteria: PeerClassMatch {
445                auth_plugin_class: Some(String::new()), // edge without its own plugin
446                delegation_profile: Some(profile_name.into()),
447                ..Default::default()
448            },
449        }
450    }
451
452    #[test]
453    fn delegation_class_match_with_valid_chain() {
454        let gw = [0xAA; 16];
455        let edge = [0xBB; 16];
456        let (chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
457
458        let mut profiles = BTreeMap::new();
459        profiles.insert(
460            "vehicle-edges".to_string(),
461            DelegationProfile::default_with_anchor(
462                "vehicle-edges".to_string(),
463                TrustAnchor {
464                    subject_guid: gw,
465                    verify_public_key: pk,
466                    algorithm: SignatureAlgorithm::EcdsaP256,
467                },
468            ),
469        );
470
471        let caps = PeerCapabilities {
472            delegation_chain: Some(chain),
473            ..Default::default()
474        };
475        let class = delegated_class("vehicle-edges");
476        let result = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None);
477        let validated = result.expect("must validate").expect("chain produced");
478        assert_eq!(validated.edge_guid, edge);
479        assert_eq!(validated.chain_depth, 1);
480    }
481
482    #[test]
483    fn delegation_class_rejects_peer_without_chain() {
484        let gw = [0xAA; 16];
485        let edge = [0xBB; 16];
486        let (_chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
487
488        let mut profiles = BTreeMap::new();
489        profiles.insert(
490            "vehicle-edges".to_string(),
491            DelegationProfile::default_with_anchor(
492                "vehicle-edges".to_string(),
493                TrustAnchor {
494                    subject_guid: gw,
495                    verify_public_key: pk,
496                    algorithm: SignatureAlgorithm::EcdsaP256,
497                },
498            ),
499        );
500
501        let caps = PeerCapabilities {
502            delegation_chain: None,
503            ..Default::default()
504        };
505        let class = delegated_class("vehicle-edges");
506        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
507            .expect_err("must fail");
508        assert!(err.contains("no chain"));
509    }
510
511    #[test]
512    fn delegation_class_rejects_unknown_profile_reference() {
513        let caps = PeerCapabilities::default();
514        let class = delegated_class("nonexistent-profile");
515        let profiles: BTreeMap<String, DelegationProfile> = BTreeMap::new();
516        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
517            .expect_err("must fail");
518        assert!(err.contains("not in governance"));
519    }
520
521    #[test]
522    fn delegation_class_rejects_invalid_chain() {
523        let gw = [0xAA; 16];
524        let edge = [0xBB; 16];
525        let (chain, _pk_correct) = make_chain_signed_by(gw, edge, &["sensor/*"]);
526        // Anchor with the wrong pubkey → validation fails.
527        let mut profiles = BTreeMap::new();
528        profiles.insert(
529            "vehicle-edges".to_string(),
530            DelegationProfile::default_with_anchor(
531                "vehicle-edges".to_string(),
532                TrustAnchor {
533                    subject_guid: gw,
534                    verify_public_key: alloc::vec![0u8; 65],
535                    algorithm: SignatureAlgorithm::EcdsaP256,
536                },
537            ),
538        );
539
540        let caps = PeerCapabilities {
541            delegation_chain: Some(chain),
542            ..Default::default()
543        };
544        let class = delegated_class("vehicle-edges");
545        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None)
546            .expect_err("must fail");
547        assert!(err.contains("validation"));
548    }
549
550    #[test]
551    fn class_without_delegation_profile_returns_ok_none() {
552        // Direct auth path: class without delegation_profile.
553        let caps = PeerCapabilities {
554            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
555            supported_suites: alloc::vec![SuiteHint::Aes128Gcm],
556            ..Default::default()
557        };
558        let class = secure_class();
559        let profiles: BTreeMap<String, DelegationProfile> = BTreeMap::new();
560        let result = peer_matches_class_with_delegation(&caps, &class, &profiles, 5_000, |_| None);
561        assert!(matches!(result, Ok(None)));
562    }
563
564    #[test]
565    fn delegation_class_rejects_chain_outside_time_window() {
566        let gw = [0xAA; 16];
567        let edge = [0xBB; 16];
568        let (chain, pk) = make_chain_signed_by(gw, edge, &["sensor/*"]);
569
570        let mut profiles = BTreeMap::new();
571        profiles.insert(
572            "vehicle-edges".to_string(),
573            DelegationProfile::default_with_anchor(
574                "vehicle-edges".to_string(),
575                TrustAnchor {
576                    subject_guid: gw,
577                    verify_public_key: pk,
578                    algorithm: SignatureAlgorithm::EcdsaP256,
579                },
580            ),
581        );
582
583        let caps = PeerCapabilities {
584            delegation_chain: Some(chain),
585            ..Default::default()
586        };
587        let class = delegated_class("vehicle-edges");
588        // now = 50_000 is well after not_after=9_000
589        let err = peer_matches_class_with_delegation(&caps, &class, &profiles, 50_000, |_| None)
590            .expect_err("must fail");
591        assert!(err.contains("validation"));
592    }
593}