1use 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
48fn 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#[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 ("", None) => {}
68 (_, 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 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 true
101}
102
103pub 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 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#[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#[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#[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#[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 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 #[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 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 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 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 let secured = secure_caps();
340 assert!(!peer_matches_class(&secured, &legacy_class()));
341 }
342
343 #[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 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 #[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 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()), 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 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 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 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}