1use 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
49fn 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#[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 ("", None) => {}
69 (_, 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 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 true
103}
104
105pub 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 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#[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#[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#[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#[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 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 #[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 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 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 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 let secured = secure_caps();
342 assert!(!peer_matches_class(&secured, &legacy_class()));
343 }
344
345 #[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 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 #[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 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()), 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 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 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 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}