1use zerodds_security_crypto::Suite;
30use zerodds_security_permissions::{Governance, ProtectionKind};
31
32use crate::caps::PeerCapabilities;
33use crate::peer_class::{interface_accepts_class, resolve_peer_class};
34use crate::policy::{
35 InboundCtx, NetInterface, OutboundCtx, PolicyDecision, PolicyEngine, ProtectionLevel, SuiteHint,
36};
37
38#[derive(Debug)]
45pub struct GovernancePolicyEngine {
46 domain_id: u32,
47 governance: Governance,
48 default_suite: SuiteHint,
52}
53
54impl GovernancePolicyEngine {
55 #[must_use]
58 pub fn new(domain_id: u32, governance: Governance, default_suite: SuiteHint) -> Self {
59 Self {
60 domain_id,
61 governance,
62 default_suite,
63 }
64 }
65
66 #[must_use]
68 pub fn with_defaults(domain_id: u32, governance: Governance) -> Self {
69 Self::new(
70 domain_id,
71 governance,
72 SuiteHint::from_suite(Suite::default()),
73 )
74 }
75
76 #[must_use]
79 pub fn message_protection_kind(&self) -> ProtectionKind {
80 self.governance
81 .find_domain_rule(self.domain_id)
82 .map(|r| r.rtps_protection_kind)
83 .unwrap_or(ProtectionKind::None)
84 }
85
86 #[must_use]
88 pub fn domain_id(&self) -> u32 {
89 self.domain_id
90 }
91
92 fn domain_decision(&self) -> PolicyDecision {
95 let kind = self.message_protection_kind();
96 self.decision_for_kind(kind)
97 }
98
99 fn decision_for_kind(&self, kind: ProtectionKind) -> PolicyDecision {
100 let level = ProtectionLevel::from_protection_kind(kind);
101 let suite = match level {
102 ProtectionLevel::None => None,
103 ProtectionLevel::Sign => Some(SuiteHint::HmacSha256),
104 ProtectionLevel::Encrypt => Some(self.default_suite),
105 };
106 PolicyDecision::with(level, suite)
107 }
108
109 fn resolve_peer_decision(
128 &self,
129 caps: &PeerCapabilities,
130 iface: &NetInterface,
131 ) -> Option<PolicyDecision> {
132 let rule = self.governance.find_domain_rule(self.domain_id)?;
133 if rule.peer_classes.is_empty() {
134 return None;
135 }
136 let class = match resolve_peer_class(caps, &rule.peer_classes) {
137 Some(c) => c,
138 None => return Some(PolicyDecision::DROP),
139 };
140
141 let iface_rule = if let Some(name) = iface_name(iface) {
143 rule.interface_bindings
144 .iter()
145 .find(|b| b.name.as_str() == name)
146 } else {
147 None
148 };
149
150 if let Some(binding) = iface_rule {
151 if !interface_accepts_class(&class.name, &binding.peer_class_filter) {
152 return Some(PolicyDecision::DROP);
153 }
154 }
155
156 let mut kind = class.protection;
158 if let Some(binding) = iface_rule {
160 if let Some(over) = binding.protection_override {
161 kind = over;
162 }
163 if let Some(min) = binding.protection_min {
166 let level_cur = ProtectionLevel::from_protection_kind(kind);
167 let level_min = ProtectionLevel::from_protection_kind(min);
168 kind = level_cur.stronger(level_min).to_protection_kind();
169 }
170 }
171 Some(self.decision_for_kind(kind))
172 }
173}
174
175fn iface_name(iface: &NetInterface) -> Option<&str> {
178 match iface {
179 NetInterface::Loopback => Some("loopback"),
180 NetInterface::LocalHost => Some("shm"),
181 NetInterface::Wan => Some("wan"),
182 NetInterface::LocalSubnet(_) => Some("local_subnet"),
183 NetInterface::Named(n) => Some(n.as_str()),
184 }
185}
186
187impl PolicyEngine for GovernancePolicyEngine {
188 fn outbound_decision(&self, ctx: OutboundCtx<'_>) -> PolicyDecision {
189 if let Some(dec) = self.resolve_peer_decision(ctx.remote_caps, ctx.interface) {
190 return dec;
191 }
192 self.domain_decision()
193 }
194
195 fn inbound_decision(&self, ctx: InboundCtx<'_>) -> PolicyDecision {
196 let expected = self.domain_decision();
197 if matches!(expected.protection, ProtectionLevel::None) && ctx.is_sec_prefixed {
201 return expected;
204 }
205 if !matches!(expected.protection, ProtectionLevel::None) && !ctx.is_sec_prefixed {
211 return PolicyDecision::DROP;
212 }
213 expected
214 }
215
216 fn accept_peer(&self, _caps: &PeerCapabilities) -> bool {
217 true
221 }
222}
223
224#[cfg(test)]
229#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
230mod tests {
231 use super::*;
232 use alloc::string::{String, ToString};
233 use alloc::vec;
234
235 use zerodds_security_crypto::AesGcmCryptoPlugin;
236 use zerodds_security_permissions::parse_governance_xml;
237
238 use crate::policy::{IpRange, NetInterface};
239 use crate::shared::{PeerKey, SharedSecurityGate};
240
241 fn gov_xml(kind: &str) -> String {
242 alloc::format!(
243 r#"
244<domain_access_rules>
245 <domain_rule>
246 <domains><id>0</id></domains>
247 <rtps_protection_kind>{kind}</rtps_protection_kind>
248 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
249 </domain_rule>
250</domain_access_rules>
251"#
252 )
253 }
254
255 fn stub_peer() -> (PeerKey, PeerCapabilities) {
256 ([0xA1; 12], PeerCapabilities::default())
257 }
258
259 fn stub_out_ctx<'a>(
260 peer: &'a PeerKey,
261 caps: &'a PeerCapabilities,
262 iface: &'a NetInterface,
263 partition: &'a [String],
264 ) -> OutboundCtx<'a> {
265 OutboundCtx {
266 domain_id: 0,
267 topic: "Chatter",
268 partition,
269 interface: iface,
270 remote_peer: peer,
271 remote_caps: caps,
272 }
273 }
274
275 #[test]
278 fn outbound_decision_matches_gate_message_protection_all_kinds() {
279 for kind in [
280 "NONE",
281 "SIGN",
282 "ENCRYPT",
283 "SIGN_WITH_ORIGIN_AUTHENTICATION",
284 "ENCRYPT_WITH_ORIGIN_AUTHENTICATION",
285 ] {
286 let gov = parse_governance_xml(&gov_xml(kind)).unwrap();
287 let engine = GovernancePolicyEngine::with_defaults(0, gov.clone());
288 let gate = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
289
290 let expected_kind = gate.message_protection().unwrap();
291 let expected_level = ProtectionLevel::from_protection_kind(expected_kind);
292
293 let (peer, caps) = stub_peer();
294 let iface = NetInterface::Wan;
295 let parts: Vec<String> = vec![];
296 let decision = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
297 assert_eq!(
298 decision.protection, expected_level,
299 "protection mismatch fuer kind={kind}"
300 );
301 assert!(!decision.drop);
302 }
303 }
304
305 #[test]
306 fn outbound_decision_suite_is_aes128_for_encrypt() {
307 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
308 let engine = GovernancePolicyEngine::with_defaults(0, gov);
309 let (peer, caps) = stub_peer();
310 let iface = NetInterface::Wan;
311 let parts: Vec<String> = vec![];
312 let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
313 assert_eq!(d.suite, Some(SuiteHint::Aes128Gcm));
314 }
315
316 #[test]
317 fn outbound_decision_suite_is_hmac_for_sign() {
318 let gov = parse_governance_xml(&gov_xml("SIGN")).unwrap();
319 let engine = GovernancePolicyEngine::with_defaults(0, gov);
320 let (peer, caps) = stub_peer();
321 let iface = NetInterface::Wan;
322 let parts: Vec<String> = vec![];
323 let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
324 assert_eq!(d.suite, Some(SuiteHint::HmacSha256));
325 assert_eq!(d.protection, ProtectionLevel::Sign);
326 }
327
328 #[test]
329 fn outbound_decision_suite_is_none_for_none() {
330 let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
331 let engine = GovernancePolicyEngine::with_defaults(0, gov);
332 let (peer, caps) = stub_peer();
333 let iface = NetInterface::Loopback;
334 let parts: Vec<String> = vec![];
335 let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
336 assert!(d.suite.is_none());
337 assert_eq!(d.protection, ProtectionLevel::None);
338 }
339
340 #[test]
341 fn outbound_decision_custom_suite_roundtrip() {
342 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
343 let engine = GovernancePolicyEngine::new(0, gov, SuiteHint::Aes256Gcm);
344 let (peer, caps) = stub_peer();
345 let iface = NetInterface::Wan;
346 let parts: Vec<String> = vec![];
347 let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
348 assert_eq!(d.suite, Some(SuiteHint::Aes256Gcm));
349 }
350
351 #[test]
352 fn inbound_plain_on_protected_domain_is_drop() {
353 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
354 let engine = GovernancePolicyEngine::with_defaults(0, gov);
355 let peer: PeerKey = [1; 12];
356 let iface = NetInterface::Wan;
357 let d = engine.inbound_decision(InboundCtx {
358 domain_id: 0,
359 source_peer: &peer,
360 source_iface: &iface,
361 source_caps: None,
362 is_sec_prefixed: false,
363 });
364 assert!(d.drop, "plaintext auf protected domain muss droppen");
365 }
366
367 #[test]
368 fn inbound_secure_on_protected_domain_is_decrypt() {
369 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
370 let engine = GovernancePolicyEngine::with_defaults(0, gov);
371 let peer: PeerKey = [1; 12];
372 let iface = NetInterface::Wan;
373 let d = engine.inbound_decision(InboundCtx {
374 domain_id: 0,
375 source_peer: &peer,
376 source_iface: &iface,
377 source_caps: None,
378 is_sec_prefixed: true,
379 });
380 assert!(!d.drop);
381 assert_eq!(d.protection, ProtectionLevel::Encrypt);
382 }
383
384 #[test]
385 fn inbound_plain_on_plain_domain_is_accept() {
386 let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
387 let engine = GovernancePolicyEngine::with_defaults(0, gov);
388 let peer: PeerKey = [1; 12];
389 let iface = NetInterface::Loopback;
390 let d = engine.inbound_decision(InboundCtx {
391 domain_id: 0,
392 source_peer: &peer,
393 source_iface: &iface,
394 source_caps: None,
395 is_sec_prefixed: false,
396 });
397 assert!(!d.drop);
398 assert_eq!(d.protection, ProtectionLevel::None);
399 }
400
401 #[test]
402 fn inbound_secure_on_plain_domain_passthrough() {
403 let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
407 let engine = GovernancePolicyEngine::with_defaults(0, gov);
408 let peer: PeerKey = [1; 12];
409 let iface = NetInterface::Loopback;
410 let d = engine.inbound_decision(InboundCtx {
411 domain_id: 0,
412 source_peer: &peer,
413 source_iface: &iface,
414 source_caps: None,
415 is_sec_prefixed: true,
416 });
417 assert!(!d.drop);
418 assert_eq!(d.protection, ProtectionLevel::None);
419 }
420
421 #[test]
422 fn accept_peer_is_always_true_in_v14_parity() {
423 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
424 let engine = GovernancePolicyEngine::with_defaults(0, gov);
425 assert!(engine.accept_peer(&PeerCapabilities::default()));
426 assert!(engine.accept_peer(&PeerCapabilities {
427 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
428 ..Default::default()
429 }));
430 }
431
432 #[test]
433 fn message_protection_kind_falls_back_to_none_when_domain_not_listed() {
434 let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
436 let engine = GovernancePolicyEngine::with_defaults(99, gov);
437 assert_eq!(engine.message_protection_kind(), ProtectionKind::None);
438 }
439
440 #[test]
441 fn domain_id_accessor_returns_constructor_value() {
442 let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
443 let engine = GovernancePolicyEngine::with_defaults(42, gov);
444 assert_eq!(engine.domain_id(), 42);
445 }
446
447 #[test]
449 fn interface_classification_is_independent_of_engine() {
450 let _r = IpRange {
451 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(10, 0, 0, 0)),
452 prefix_len: 24,
453 };
454 }
455
456 const HETERO_GOV_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
461<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
462 <domain_access_rules>
463 <domain_rule>
464 <domains><id>0</id></domains>
465 <rtps_protection_kind>SIGN</rtps_protection_kind>
466 <zerodds:peer_classes>
467 <zerodds:peer_class name="legacy" protection="NONE">
468 <zerodds:match auth_plugin_class="" />
469 </zerodds:peer_class>
470 <zerodds:peer_class name="fast" protection="SIGN">
471 <zerodds:match cert_cn_pattern="*.fast.example" />
472 </zerodds:peer_class>
473 <zerodds:peer_class name="secure" protection="ENCRYPT">
474 <zerodds:match auth_plugin_class="DDS:Auth:PKI-DH:1.2" suite="AES_128_GCM" />
475 </zerodds:peer_class>
476 <zerodds:peer_class name="highassurance" protection="ENCRYPT">
477 <zerodds:match cert_cn_pattern="*.ha.*" suite="AES_256_GCM" require_ocsp="TRUE" />
478 </zerodds:peer_class>
479 </zerodds:peer_classes>
480 <zerodds:interface_bindings>
481 <zerodds:interface name="loopback" protection_override="NONE" />
482 <zerodds:interface name="shm" protection_override="NONE" />
483 <zerodds:interface name="eth0" peer_class_filter="legacy,fast,secure" />
484 <zerodds:interface name="tun0" peer_class_filter="secure,highassurance"
485 protection_min="ENCRYPT" />
486 </zerodds:interface_bindings>
487 </domain_rule>
488 </domain_access_rules>
489</dds>"#;
490
491 fn hetero_engine() -> GovernancePolicyEngine {
492 let gov = parse_governance_xml(HETERO_GOV_XML).unwrap();
493 GovernancePolicyEngine::with_defaults(0, gov)
494 }
495
496 fn mk_out_ctx<'a>(
497 peer: &'a PeerKey,
498 caps: &'a PeerCapabilities,
499 iface: &'a NetInterface,
500 parts: &'a [String],
501 ) -> OutboundCtx<'a> {
502 OutboundCtx {
503 domain_id: 0,
504 topic: "Chatter",
505 partition: parts,
506 interface: iface,
507 remote_peer: peer,
508 remote_caps: caps,
509 }
510 }
511
512 #[test]
513 fn hetero_dod_legacy_peer_on_eth0_gets_none() {
514 let engine = hetero_engine();
516 let peer: PeerKey = [1; 12];
517 let caps = PeerCapabilities::default();
518 let iface = NetInterface::Named("eth0".into());
519 let parts: Vec<String> = vec![];
520 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
521 assert_eq!(dec.protection, ProtectionLevel::None);
522 assert!(!dec.drop);
523 }
524
525 #[test]
526 fn hetero_dod_fast_peer_on_eth0_gets_sign() {
527 let engine = hetero_engine();
528 let peer: PeerKey = [2; 12];
529 let caps = PeerCapabilities {
530 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
531 cert_cn: Some("writer.fast.example".into()),
532 supported_suites: vec![SuiteHint::HmacSha256],
533 ..Default::default()
534 };
535 let iface = NetInterface::Named("eth0".into());
536 let parts: Vec<String> = vec![];
537 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
538 assert_eq!(dec.protection, ProtectionLevel::Sign);
539 }
540
541 #[test]
542 fn hetero_dod_secure_peer_on_eth0_gets_encrypt() {
543 let engine = hetero_engine();
544 let peer: PeerKey = [3; 12];
545 let caps = PeerCapabilities {
546 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
547 supported_suites: vec![SuiteHint::Aes128Gcm],
548 ..Default::default()
549 };
550 let iface = NetInterface::Named("eth0".into());
551 let parts: Vec<String> = vec![];
552 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
553 assert_eq!(dec.protection, ProtectionLevel::Encrypt);
554 }
555
556 #[test]
557 fn hetero_dod_ha_peer_on_tun0_gets_encrypt() {
558 let engine = hetero_engine();
559 let peer: PeerKey = [4; 12];
560 let caps = PeerCapabilities {
561 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
562 cert_cn: Some("w1.ha.corp".into()),
563 supported_suites: vec![SuiteHint::Aes256Gcm],
564 has_valid_cert: true,
565 ..Default::default()
566 };
567 let iface = NetInterface::Named("tun0".into());
568 let parts: Vec<String> = vec![];
569 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
570 assert_eq!(dec.protection, ProtectionLevel::Encrypt);
571 }
572
573 #[test]
574 fn hetero_interface_override_loopback_forces_none() {
575 let engine = hetero_engine();
578 let peer: PeerKey = [5; 12];
579 let caps = PeerCapabilities {
580 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
581 supported_suites: vec![SuiteHint::Aes128Gcm],
582 ..Default::default()
583 };
584 let iface = NetInterface::Loopback;
585 let parts: Vec<String> = vec![];
586 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
587 assert_eq!(
588 dec.protection,
589 ProtectionLevel::None,
590 "loopback-override muss Class-Encrypt ueberschreiben"
591 );
592 }
593
594 #[test]
595 fn hetero_interface_filter_rejects_legacy_on_tun0() {
596 let engine = hetero_engine();
599 let peer: PeerKey = [6; 12];
600 let caps = PeerCapabilities::default(); let iface = NetInterface::Named("tun0".into());
602 let parts: Vec<String> = vec![];
603 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
604 assert!(dec.drop, "Legacy-Peer darf nicht auf tun0 → drop");
605 }
606
607 #[test]
608 fn hetero_no_matching_peer_class_drops() {
609 let engine = hetero_engine();
612 let peer: PeerKey = [7; 12];
613 let caps = PeerCapabilities {
614 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
617 cert_cn: Some("unknown.zone".into()),
618 supported_suites: vec![],
619 ..Default::default()
620 };
621 let iface = NetInterface::Named("eth0".into());
622 let parts: Vec<String> = vec![];
623 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
624 assert!(dec.drop, "Peer in keiner Klasse → drop");
625 }
626
627 #[test]
628 fn hetero_interface_protection_min_upgrades_sign_to_encrypt() {
629 let engine = hetero_engine();
636 let peer: PeerKey = [8; 12];
637 let caps = PeerCapabilities {
638 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
639 supported_suites: vec![SuiteHint::Aes128Gcm],
640 ..Default::default()
641 };
642 let iface = NetInterface::Named("tun0".into());
643 let parts: Vec<String> = vec![];
644 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
645 assert_eq!(dec.protection, ProtectionLevel::Encrypt);
646 }
647
648 #[test]
649 fn legacy_xml_without_peer_classes_falls_back_to_domain_rule() {
650 let engine = GovernancePolicyEngine::with_defaults(
653 0,
654 parse_governance_xml(&gov_xml("SIGN")).unwrap(),
655 );
656 let peer: PeerKey = [9; 12];
657 let caps = PeerCapabilities {
658 auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
659 supported_suites: vec![SuiteHint::Aes128Gcm],
660 ..Default::default()
661 };
662 let iface = NetInterface::Wan;
663 let parts: Vec<String> = vec![];
664 let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
665 assert_eq!(
666 dec.protection,
667 ProtectionLevel::Sign,
668 "ohne peer_classes muss Domain-Rule greifen"
669 );
670 }
671}