use zerodds_security_crypto::Suite;
use zerodds_security_permissions::{Governance, ProtectionKind};
use crate::caps::PeerCapabilities;
use crate::peer_class::{interface_accepts_class, resolve_peer_class};
use crate::policy::{
InboundCtx, NetInterface, OutboundCtx, PolicyDecision, PolicyEngine, ProtectionLevel, SuiteHint,
};
#[derive(Debug)]
pub struct GovernancePolicyEngine {
domain_id: u32,
governance: Governance,
default_suite: SuiteHint,
}
impl GovernancePolicyEngine {
#[must_use]
pub fn new(domain_id: u32, governance: Governance, default_suite: SuiteHint) -> Self {
Self {
domain_id,
governance,
default_suite,
}
}
#[must_use]
pub fn with_defaults(domain_id: u32, governance: Governance) -> Self {
Self::new(
domain_id,
governance,
SuiteHint::from_suite(Suite::default()),
)
}
#[must_use]
pub fn message_protection_kind(&self) -> ProtectionKind {
self.governance
.find_domain_rule(self.domain_id)
.map(|r| r.rtps_protection_kind)
.unwrap_or(ProtectionKind::None)
}
#[must_use]
pub fn domain_id(&self) -> u32 {
self.domain_id
}
fn domain_decision(&self) -> PolicyDecision {
let kind = self.message_protection_kind();
self.decision_for_kind(kind)
}
fn decision_for_kind(&self, kind: ProtectionKind) -> PolicyDecision {
let level = ProtectionLevel::from_protection_kind(kind);
let suite = match level {
ProtectionLevel::None => None,
ProtectionLevel::Sign => Some(SuiteHint::HmacSha256),
ProtectionLevel::Encrypt => Some(self.default_suite),
};
PolicyDecision::with(level, suite)
}
fn resolve_peer_decision(
&self,
caps: &PeerCapabilities,
iface: &NetInterface,
) -> Option<PolicyDecision> {
let rule = self.governance.find_domain_rule(self.domain_id)?;
if rule.peer_classes.is_empty() {
return None;
}
let class = match resolve_peer_class(caps, &rule.peer_classes) {
Some(c) => c,
None => return Some(PolicyDecision::DROP),
};
let iface_rule = if let Some(name) = iface_name(iface) {
rule.interface_bindings
.iter()
.find(|b| b.name.as_str() == name)
} else {
None
};
if let Some(binding) = iface_rule {
if !interface_accepts_class(&class.name, &binding.peer_class_filter) {
return Some(PolicyDecision::DROP);
}
}
let mut kind = class.protection;
if let Some(binding) = iface_rule {
if let Some(over) = binding.protection_override {
kind = over;
}
if let Some(min) = binding.protection_min {
let level_cur = ProtectionLevel::from_protection_kind(kind);
let level_min = ProtectionLevel::from_protection_kind(min);
kind = level_cur.stronger(level_min).to_protection_kind();
}
}
Some(self.decision_for_kind(kind))
}
}
fn iface_name(iface: &NetInterface) -> Option<&str> {
match iface {
NetInterface::Loopback => Some("loopback"),
NetInterface::LocalHost => Some("shm"),
NetInterface::Wan => Some("wan"),
NetInterface::LocalSubnet(_) => Some("local_subnet"),
NetInterface::Named(n) => Some(n.as_str()),
}
}
impl PolicyEngine for GovernancePolicyEngine {
fn outbound_decision(&self, ctx: OutboundCtx<'_>) -> PolicyDecision {
if let Some(dec) = self.resolve_peer_decision(ctx.remote_caps, ctx.interface) {
return dec;
}
self.domain_decision()
}
fn inbound_decision(&self, ctx: InboundCtx<'_>) -> PolicyDecision {
let expected = self.domain_decision();
if matches!(expected.protection, ProtectionLevel::None) && ctx.is_sec_prefixed {
return expected;
}
if !matches!(expected.protection, ProtectionLevel::None) && !ctx.is_sec_prefixed {
return PolicyDecision::DROP;
}
expected
}
fn accept_peer(&self, _caps: &PeerCapabilities) -> bool {
true
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use alloc::string::{String, ToString};
use alloc::vec;
use zerodds_security_crypto::AesGcmCryptoPlugin;
use zerodds_security_permissions::parse_governance_xml;
use crate::policy::{IpRange, NetInterface};
use crate::shared::{PeerKey, SharedSecurityGate};
fn gov_xml(kind: &str) -> String {
alloc::format!(
r#"
<domain_access_rules>
<domain_rule>
<domains><id>0</id></domains>
<rtps_protection_kind>{kind}</rtps_protection_kind>
<topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
</domain_rule>
</domain_access_rules>
"#
)
}
fn stub_peer() -> (PeerKey, PeerCapabilities) {
([0xA1; 12], PeerCapabilities::default())
}
fn stub_out_ctx<'a>(
peer: &'a PeerKey,
caps: &'a PeerCapabilities,
iface: &'a NetInterface,
partition: &'a [String],
) -> OutboundCtx<'a> {
OutboundCtx {
domain_id: 0,
topic: "Chatter",
partition,
interface: iface,
remote_peer: peer,
remote_caps: caps,
}
}
#[test]
fn outbound_decision_matches_gate_message_protection_all_kinds() {
for kind in [
"NONE",
"SIGN",
"ENCRYPT",
"SIGN_WITH_ORIGIN_AUTHENTICATION",
"ENCRYPT_WITH_ORIGIN_AUTHENTICATION",
] {
let gov = parse_governance_xml(&gov_xml(kind)).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov.clone());
let gate = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
let expected_kind = gate.message_protection().unwrap();
let expected_level = ProtectionLevel::from_protection_kind(expected_kind);
let (peer, caps) = stub_peer();
let iface = NetInterface::Wan;
let parts: Vec<String> = vec![];
let decision = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(
decision.protection, expected_level,
"protection mismatch fuer kind={kind}"
);
assert!(!decision.drop);
}
}
#[test]
fn outbound_decision_suite_is_aes128_for_encrypt() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let (peer, caps) = stub_peer();
let iface = NetInterface::Wan;
let parts: Vec<String> = vec![];
let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(d.suite, Some(SuiteHint::Aes128Gcm));
}
#[test]
fn outbound_decision_suite_is_hmac_for_sign() {
let gov = parse_governance_xml(&gov_xml("SIGN")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let (peer, caps) = stub_peer();
let iface = NetInterface::Wan;
let parts: Vec<String> = vec![];
let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(d.suite, Some(SuiteHint::HmacSha256));
assert_eq!(d.protection, ProtectionLevel::Sign);
}
#[test]
fn outbound_decision_suite_is_none_for_none() {
let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let (peer, caps) = stub_peer();
let iface = NetInterface::Loopback;
let parts: Vec<String> = vec![];
let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
assert!(d.suite.is_none());
assert_eq!(d.protection, ProtectionLevel::None);
}
#[test]
fn outbound_decision_custom_suite_roundtrip() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::new(0, gov, SuiteHint::Aes256Gcm);
let (peer, caps) = stub_peer();
let iface = NetInterface::Wan;
let parts: Vec<String> = vec![];
let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(d.suite, Some(SuiteHint::Aes256Gcm));
}
#[test]
fn inbound_plain_on_protected_domain_is_drop() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let peer: PeerKey = [1; 12];
let iface = NetInterface::Wan;
let d = engine.inbound_decision(InboundCtx {
domain_id: 0,
source_peer: &peer,
source_iface: &iface,
source_caps: None,
is_sec_prefixed: false,
});
assert!(d.drop, "plaintext auf protected domain muss droppen");
}
#[test]
fn inbound_secure_on_protected_domain_is_decrypt() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let peer: PeerKey = [1; 12];
let iface = NetInterface::Wan;
let d = engine.inbound_decision(InboundCtx {
domain_id: 0,
source_peer: &peer,
source_iface: &iface,
source_caps: None,
is_sec_prefixed: true,
});
assert!(!d.drop);
assert_eq!(d.protection, ProtectionLevel::Encrypt);
}
#[test]
fn inbound_plain_on_plain_domain_is_accept() {
let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let peer: PeerKey = [1; 12];
let iface = NetInterface::Loopback;
let d = engine.inbound_decision(InboundCtx {
domain_id: 0,
source_peer: &peer,
source_iface: &iface,
source_caps: None,
is_sec_prefixed: false,
});
assert!(!d.drop);
assert_eq!(d.protection, ProtectionLevel::None);
}
#[test]
fn inbound_secure_on_plain_domain_passthrough() {
let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
let peer: PeerKey = [1; 12];
let iface = NetInterface::Loopback;
let d = engine.inbound_decision(InboundCtx {
domain_id: 0,
source_peer: &peer,
source_iface: &iface,
source_caps: None,
is_sec_prefixed: true,
});
assert!(!d.drop);
assert_eq!(d.protection, ProtectionLevel::None);
}
#[test]
fn accept_peer_is_always_true_in_v14_parity() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(0, gov);
assert!(engine.accept_peer(&PeerCapabilities::default()));
assert!(engine.accept_peer(&PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
..Default::default()
}));
}
#[test]
fn message_protection_kind_falls_back_to_none_when_domain_not_listed() {
let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(99, gov);
assert_eq!(engine.message_protection_kind(), ProtectionKind::None);
}
#[test]
fn domain_id_accessor_returns_constructor_value() {
let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
let engine = GovernancePolicyEngine::with_defaults(42, gov);
assert_eq!(engine.domain_id(), 42);
}
#[test]
fn interface_classification_is_independent_of_engine() {
let _r = IpRange {
base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(10, 0, 0, 0)),
prefix_len: 24,
};
}
const HETERO_GOV_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
<domain_access_rules>
<domain_rule>
<domains><id>0</id></domains>
<rtps_protection_kind>SIGN</rtps_protection_kind>
<zerodds:peer_classes>
<zerodds:peer_class name="legacy" protection="NONE">
<zerodds:match auth_plugin_class="" />
</zerodds:peer_class>
<zerodds:peer_class name="fast" protection="SIGN">
<zerodds:match cert_cn_pattern="*.fast.example" />
</zerodds:peer_class>
<zerodds:peer_class name="secure" protection="ENCRYPT">
<zerodds:match auth_plugin_class="DDS:Auth:PKI-DH:1.2" suite="AES_128_GCM" />
</zerodds:peer_class>
<zerodds:peer_class name="highassurance" protection="ENCRYPT">
<zerodds:match cert_cn_pattern="*.ha.*" suite="AES_256_GCM" require_ocsp="TRUE" />
</zerodds:peer_class>
</zerodds:peer_classes>
<zerodds:interface_bindings>
<zerodds:interface name="loopback" protection_override="NONE" />
<zerodds:interface name="shm" protection_override="NONE" />
<zerodds:interface name="eth0" peer_class_filter="legacy,fast,secure" />
<zerodds:interface name="tun0" peer_class_filter="secure,highassurance"
protection_min="ENCRYPT" />
</zerodds:interface_bindings>
</domain_rule>
</domain_access_rules>
</dds>"#;
fn hetero_engine() -> GovernancePolicyEngine {
let gov = parse_governance_xml(HETERO_GOV_XML).unwrap();
GovernancePolicyEngine::with_defaults(0, gov)
}
fn mk_out_ctx<'a>(
peer: &'a PeerKey,
caps: &'a PeerCapabilities,
iface: &'a NetInterface,
parts: &'a [String],
) -> OutboundCtx<'a> {
OutboundCtx {
domain_id: 0,
topic: "Chatter",
partition: parts,
interface: iface,
remote_peer: peer,
remote_caps: caps,
}
}
#[test]
fn hetero_dod_legacy_peer_on_eth0_gets_none() {
let engine = hetero_engine();
let peer: PeerKey = [1; 12];
let caps = PeerCapabilities::default();
let iface = NetInterface::Named("eth0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(dec.protection, ProtectionLevel::None);
assert!(!dec.drop);
}
#[test]
fn hetero_dod_fast_peer_on_eth0_gets_sign() {
let engine = hetero_engine();
let peer: PeerKey = [2; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
cert_cn: Some("writer.fast.example".into()),
supported_suites: vec![SuiteHint::HmacSha256],
..Default::default()
};
let iface = NetInterface::Named("eth0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(dec.protection, ProtectionLevel::Sign);
}
#[test]
fn hetero_dod_secure_peer_on_eth0_gets_encrypt() {
let engine = hetero_engine();
let peer: PeerKey = [3; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
supported_suites: vec![SuiteHint::Aes128Gcm],
..Default::default()
};
let iface = NetInterface::Named("eth0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(dec.protection, ProtectionLevel::Encrypt);
}
#[test]
fn hetero_dod_ha_peer_on_tun0_gets_encrypt() {
let engine = hetero_engine();
let peer: PeerKey = [4; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
cert_cn: Some("w1.ha.corp".into()),
supported_suites: vec![SuiteHint::Aes256Gcm],
has_valid_cert: true,
..Default::default()
};
let iface = NetInterface::Named("tun0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(dec.protection, ProtectionLevel::Encrypt);
}
#[test]
fn hetero_interface_override_loopback_forces_none() {
let engine = hetero_engine();
let peer: PeerKey = [5; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
supported_suites: vec![SuiteHint::Aes128Gcm],
..Default::default()
};
let iface = NetInterface::Loopback;
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(
dec.protection,
ProtectionLevel::None,
"loopback-override muss Class-Encrypt ueberschreiben"
);
}
#[test]
fn hetero_interface_filter_rejects_legacy_on_tun0() {
let engine = hetero_engine();
let peer: PeerKey = [6; 12];
let caps = PeerCapabilities::default(); let iface = NetInterface::Named("tun0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert!(dec.drop, "Legacy-Peer darf nicht auf tun0 → drop");
}
#[test]
fn hetero_no_matching_peer_class_drops() {
let engine = hetero_engine();
let peer: PeerKey = [7; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
cert_cn: Some("unknown.zone".into()),
supported_suites: vec![],
..Default::default()
};
let iface = NetInterface::Named("eth0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert!(dec.drop, "Peer in keiner Klasse → drop");
}
#[test]
fn hetero_interface_protection_min_upgrades_sign_to_encrypt() {
let engine = hetero_engine();
let peer: PeerKey = [8; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
supported_suites: vec![SuiteHint::Aes128Gcm],
..Default::default()
};
let iface = NetInterface::Named("tun0".into());
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(dec.protection, ProtectionLevel::Encrypt);
}
#[test]
fn legacy_xml_without_peer_classes_falls_back_to_domain_rule() {
let engine = GovernancePolicyEngine::with_defaults(
0,
parse_governance_xml(&gov_xml("SIGN")).unwrap(),
);
let peer: PeerKey = [9; 12];
let caps = PeerCapabilities {
auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
supported_suites: vec![SuiteHint::Aes128Gcm],
..Default::default()
};
let iface = NetInterface::Wan;
let parts: Vec<String> = vec![];
let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
assert_eq!(
dec.protection,
ProtectionLevel::Sign,
"ohne peer_classes muss Domain-Rule greifen"
);
}
}