Skip to main content

zerodds_security_runtime/
shared.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Thread-safe Wrapper um [`SecurityGate`] fuer Multi-Thread-Nutzung.
5//!
6//! Die referenz-basierte `SecurityGate<'c, P>` ist fuer Einzel-Thread-
7//! Nutzung gedacht — mehrere Runtime-Threads (SPDP-Loop, User-RX,
8//! Event-Loop) brauchen aber synchronisierten Zugriff.
9//!
10//! [`SharedSecurityGate`] kapselt:
11//! * `Governance` (immutable pro Teilnehmer — Clone-bar).
12//! * `Box<dyn CryptographicPlugin>` (mutable beim Key-Registrieren).
13//! * Cache des lokalen CryptoHandles.
14//!
15//! zerodds-lint: allow no_dyn_in_safe
16//! (Plugin wird via `Box<dyn CryptographicPlugin>` gehalten, damit
17//! der Nutzer das Backend frei waehlen kann.)
18
19use alloc::boxed::Box;
20use alloc::collections::BTreeMap;
21use alloc::vec::Vec;
22use std::sync::{Arc, Mutex, PoisonError};
23
24use zerodds_security::authentication::{IdentityHandle, SharedSecretHandle};
25use zerodds_security::crypto::{CryptoHandle, CryptographicPlugin};
26use zerodds_security_permissions::{Governance, ProtectionKind};
27use zerodds_security_rtps::{
28    RTPS_HEADER_LEN, SRTPS_PREFIX, decode_secured_rtps_message, decode_secured_submessage_multi,
29    encode_secured_rtps_message, encode_secured_submessage_multi,
30};
31
32use crate::gate::SecurityGateError;
33use crate::policy::{NetInterface, ProtectionLevel};
34
35// ============================================================================
36// Inbound-Verdict
37// ============================================================================
38
39/// Ergebnis einer `classify_inbound`-Entscheidung.
40///
41/// Die Enum-Varianten trennen die moeglichen Gruende sauber, damit der
42/// Caller (dcps-Runtime) pro Grund einen passenden `LogLevel` an das
43/// [`zerodds_security::logging::LoggingPlugin`] weiterreichen kann.
44///
45/// Der Interface-Kontext (`NetInterface`) wird vom Caller mit
46/// uebergeben und findet sich in [`InboundVerdict::iface`] wieder —
47/// damit Log-Events pro Interface attributierbar sind.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum InboundVerdict {
50    /// Paket ist zulaessig — `bytes` ist das dekodierte RTPS-Datagram,
51    /// das an den SPDP/SEDP/User-Dispatch weitergegeben wird.
52    Accept(Vec<u8>),
53    /// Paket ist zu kurz fuer einen RTPS-Header (< 20 Byte). Das ist
54    /// eigentlich ein Transport-Bug oder ein Fuzz-Probe. Severity
55    /// sollte `Error` sein.
56    Malformed,
57    /// Paket kam von einem **unauth-Peer auf einer Domain, die
58    /// Authentication verlangt** (`allow_unauthenticated_participants =
59    /// false`). Severity sollte `Error` sein.
60    LegacyBlocked,
61    /// Policy-Violation: die Domain verlangt Protection, das Paket
62    /// ist aber plain (oder umgekehrt). Severity sollte `Warning`
63    /// sein — ggf. Tampering-Versuch.
64    PolicyViolation(String),
65    /// Cryptographischer Fehler beim Unwrap (Tag-Mismatch, falscher
66    /// Key, replay-Attack etc.). Severity `Warning`.
67    CryptoError(String),
68}
69
70impl InboundVerdict {
71    /// Kurzform: `true` wenn Paket weitergegeben wird.
72    #[must_use]
73    pub const fn is_accept(&self) -> bool {
74        matches!(self, Self::Accept(_))
75    }
76
77    /// Log-Kategorie (OMG §8.6.3) — freier String, der den Drop-Grund
78    /// identifiziert. Hilfreich wenn ein LoggingPlugin nach Kategorien
79    /// filtert.
80    #[must_use]
81    pub fn category(&self) -> &'static str {
82        match self {
83            Self::Accept(_) => "inbound.accept",
84            Self::Malformed => "inbound.malformed",
85            Self::LegacyBlocked => "inbound.legacy_blocked",
86            Self::PolicyViolation(_) => "inbound.policy_violation",
87            Self::CryptoError(_) => "inbound.crypto_error",
88        }
89    }
90}
91
92/// Opaker Peer-Identifier. In RTPS-Umgebungen mappt der Caller typisch
93/// `GuidPrefix` (12 byte) darauf — `[u8; 12]` passt genau.
94pub type PeerKey = [u8; 12];
95
96/// Thread-sicherer Security-Gate. Clone gibt eine zweite Referenz auf
97/// die gleiche Plugin-Instance — alle Clones operieren auf gleichem
98/// Key-Store.
99#[derive(Clone)]
100pub struct SharedSecurityGate {
101    inner: Arc<Mutex<GateInner>>,
102}
103
104impl core::fmt::Debug for SharedSecurityGate {
105    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
106        // Plugin + Keys niemals in Debug-Output — nur Metadaten.
107        match self.inner.lock() {
108            Ok(g) => write!(
109                f,
110                "SharedSecurityGate {{ domain_id: {}, peers: {}, local_registered: {} }}",
111                g.domain_id,
112                g.peers.len(),
113                g.local.is_some()
114            ),
115            Err(_) => write!(f, "SharedSecurityGate {{ <poisoned> }}"),
116        }
117    }
118}
119
120struct GateInner {
121    domain_id: u32,
122    governance: Governance,
123    crypto: Box<dyn CryptographicPlugin>,
124    local: Option<CryptoHandle>,
125    /// Peer-Key → CryptoHandle-Mapping. Wird von
126    /// `register_remote_by_guid` gefuellt; `transform_inbound_from`
127    /// schaut hier nach.
128    peers: BTreeMap<PeerKey, CryptoHandle>,
129}
130
131/// Leitet eine deterministische 4-Byte `key_id` aus einem
132/// 12-Byte [`PeerKey`] (GuidPrefix) ab. Beide Kommunikationspartner
133/// berechnen sie identisch ohne Handshake, d.h. die `key_id` im
134/// Wire-Format-SEC_POSTFIX ist plattformuebergreifend
135/// synchronisierbar.
136///
137/// Verwendung: Low-32-bits der ersten 4 Bytes des GuidPrefix.
138#[must_use]
139pub fn peer_key_to_id(pk: &PeerKey) -> u32 {
140    let mut buf = [0u8; 4];
141    buf.copy_from_slice(&pk[..4]);
142    u32::from_le_bytes(buf)
143}
144
145fn poisoned<T>(_: PoisonError<T>) -> SecurityGateError {
146    SecurityGateError::Crypto(zerodds_security::error::SecurityError::new(
147        zerodds_security::error::SecurityErrorKind::Internal,
148        "security-runtime: mutex poisoned",
149    ))
150}
151
152impl SharedSecurityGate {
153    /// Konstruktor. Der Plugin wird vom Gate **geownt** (Box).
154    #[must_use]
155    pub fn new(
156        domain_id: u32,
157        governance: Governance,
158        crypto: Box<dyn CryptographicPlugin>,
159    ) -> Self {
160        Self {
161            inner: Arc::new(Mutex::new(GateInner {
162                domain_id,
163                governance,
164                crypto,
165                local: None,
166                peers: BTreeMap::new(),
167            })),
168        }
169    }
170
171    fn with_inner<R>(
172        &self,
173        f: impl FnOnce(&mut GateInner) -> Result<R, SecurityGateError>,
174    ) -> Result<R, SecurityGateError> {
175        let mut g = self.inner.lock().map_err(poisoned)?;
176        f(&mut g)
177    }
178
179    /// Gibt die Domain-Id zurueck, fuer die der Gate laeuft.
180    pub fn domain_id(&self) -> Result<u32, SecurityGateError> {
181        self.with_inner(|g| Ok(g.domain_id))
182    }
183
184    /// `ProtectionKind` fuer Message-Level — abgeleitet aus dem ersten
185    /// matchenden `<domain_rule>`.
186    pub fn message_protection(&self) -> Result<ProtectionKind, SecurityGateError> {
187        self.with_inner(|g| {
188            Ok(g.governance
189                .find_domain_rule(g.domain_id)
190                .map(|r| r.rtps_protection_kind)
191                .unwrap_or(ProtectionKind::None))
192        })
193    }
194
195    /// Registriert den lokalen Participant beim Crypto-Plugin (idempotent).
196    ///
197    /// # Errors
198    /// `CryptoSetup` wenn das Plugin den Identity-Handle nicht akzeptiert.
199    pub fn ensure_local(&self) -> Result<CryptoHandle, SecurityGateError> {
200        self.with_inner(|g| {
201            if let Some(h) = g.local {
202                return Ok(h);
203            }
204            let h = g
205                .crypto
206                .register_local_participant(IdentityHandle(1), &[])
207                .map_err(SecurityGateError::CryptoSetup)?;
208            g.local = Some(h);
209            Ok(h)
210        })
211    }
212
213    /// Token des lokalen Participants (zu senden an Remote via SEDP).
214    ///
215    /// # Errors
216    /// Siehe [`SecurityGateError`].
217    pub fn local_token(&self) -> Result<Vec<u8>, SecurityGateError> {
218        let local = self.ensure_local()?;
219        self.with_inner(|g| {
220            g.crypto
221                .create_local_participant_crypto_tokens(local, CryptoHandle(0))
222                .map_err(SecurityGateError::Crypto)
223        })
224    }
225
226    /// Registriert einen Remote-Peer und installiert seinen Token.
227    /// Der zurueckgegebene `CryptoHandle` identifiziert den Slot, in
228    /// dem der Remote-Key abgelegt ist — wird bei `decode_inbound_message`
229    /// wieder gebraucht.
230    ///
231    /// # Errors
232    /// Siehe [`SecurityGateError`].
233    pub fn register_remote_with_token(
234        &self,
235        remote_identity: IdentityHandle,
236        shared_secret: SharedSecretHandle,
237        token: &[u8],
238    ) -> Result<CryptoHandle, SecurityGateError> {
239        let local = self.ensure_local()?;
240        self.with_inner(|g| {
241            let slot = g
242                .crypto
243                .register_matched_remote_participant(local, remote_identity, shared_secret)
244                .map_err(SecurityGateError::CryptoSetup)?;
245            g.crypto
246                .set_remote_participant_crypto_tokens(local, slot, token)
247                .map_err(SecurityGateError::Crypto)?;
248            Ok(slot)
249        })
250    }
251
252    /// Registriert einen Remote-Peer **mit Peer-Key-Mapping**. Nach
253    /// diesem Call kann [`Self::transform_inbound_from`] den Peer
254    /// anhand seines [`PeerKey`] (GuidPrefix) wiederfinden.
255    ///
256    /// Idempotent: wiederholter Call mit gleichem Key ueberschreibt den
257    /// alten Slot nicht — der Caller muss explizit
258    /// [`Self::forget_remote`] aufrufen, um rotieren zu koennen.
259    ///
260    /// # Errors
261    /// Siehe [`SecurityGateError`].
262    pub fn register_remote_by_guid(
263        &self,
264        peer_key: PeerKey,
265        remote_identity: IdentityHandle,
266        shared_secret: SharedSecretHandle,
267        token: &[u8],
268    ) -> Result<CryptoHandle, SecurityGateError> {
269        // Idempotenz-Check: wenn schon registriert, die existierende
270        // Handle zurueckgeben.
271        {
272            let g = self.inner.lock().map_err(poisoned)?;
273            if let Some(h) = g.peers.get(&peer_key) {
274                return Ok(*h);
275            }
276        }
277        let slot = self.register_remote_with_token(remote_identity, shared_secret, token)?;
278        self.with_inner(|g| {
279            g.peers.insert(peer_key, slot);
280            Ok(())
281        })?;
282        Ok(slot)
283    }
284
285    /// Entfernt die Peer-Key → Slot-Zuordnung. Der Slot selbst bleibt
286    /// im Plugin (Key-Cleanup ist Aufgabe des Plugins bei Re-Register).
287    pub fn forget_remote(&self, peer_key: &PeerKey) -> Result<(), SecurityGateError> {
288        self.with_inner(|g| {
289            g.peers.remove(peer_key);
290            Ok(())
291        })
292    }
293
294    /// Liefert den Slot fuer einen Peer-Key, falls registriert.
295    pub fn slot_for(&self, peer_key: &PeerKey) -> Result<Option<CryptoHandle>, SecurityGateError> {
296        self.with_inner(|g| Ok(g.peers.get(peer_key).copied()))
297    }
298
299    /// Inbound-Transform mit **Peer-Key-Lookup**. Der RTPS-Header
300    /// enthaelt den GuidPrefix des Senders auf den Bytes 8..20 — der
301    /// Caller muss diesen hier als `peer_key` uebergeben.
302    ///
303    /// Wenn der Peer nicht registriert ist **und** die Message
304    /// verschluesselt aussieht: `PolicyViolation` (unbekannter Sender
305    /// schickt SRTPS).
306    ///
307    /// # Errors
308    /// Siehe [`SecurityGateError`].
309    pub fn transform_inbound_from(
310        &self,
311        peer_key: &PeerKey,
312        wire: &[u8],
313    ) -> Result<Vec<u8>, SecurityGateError> {
314        let looks_secured = wire.len() > RTPS_HEADER_LEN && wire[RTPS_HEADER_LEN] == SRTPS_PREFIX;
315        let kind = self.message_protection()?;
316        if !looks_secured {
317            // Passthrough oder Policy-Violation — gleiche Logik wie im
318            // slot-basierten Pfad.
319            return if matches!(kind, ProtectionKind::None) {
320                Ok(wire.to_vec())
321            } else {
322                Err(SecurityGateError::PolicyViolation(alloc::format!(
323                    "domain verlangt {kind:?}, bekam plain-rtps-message"
324                )))
325            };
326        }
327        let slot = self.slot_for(peer_key)?.ok_or_else(|| {
328            SecurityGateError::PolicyViolation(alloc::format!(
329                "unbekannter peer {peer_key:?} sendet SRTPS_PREFIX"
330            ))
331        })?;
332        self.transform_inbound(slot, wire)
333    }
334
335    /// Outbound-Message: wrap wenn Governance Message-Schutz verlangt.
336    ///
337    /// # Errors
338    /// Siehe [`SecurityGateError`].
339    pub fn transform_outbound(&self, message: &[u8]) -> Result<Vec<u8>, SecurityGateError> {
340        match self.message_protection()? {
341            ProtectionKind::None => Ok(message.to_vec()),
342            _ => {
343                let local = self.ensure_local()?;
344                self.with_inner(|g| {
345                    encode_secured_rtps_message(&*g.crypto, local, &[], message)
346                        .map_err(SecurityGateError::from)
347                })
348            }
349        }
350    }
351
352    /// Group-Outbound mit Receiver-Specific-MACs.
353    ///
354    /// Nutzt [`encode_secured_submessage_multi`], wenn alle Empfaenger
355    /// bereits ueber Peer-Keys im Gate registriert sind. Liefert einen
356    /// **einzigen** Wire-Datagram mit Multi-MAC-SEC_POSTFIX; der
357    /// Caller kann dasselbe Datagram an alle matched Readers unicasten.
358    ///
359    /// Der resultierende Wire ist KEIN RTPS-Message-Level-Wrapper —
360    /// es ist eine Submessage-Sequenz (SEC_PREFIX/BODY/POSTFIX). Der
361    /// Caller muss den RTPS-Header selber davor setzen oder die
362    /// `transform_outbound_multi_wrapped`-Variante nutzen (folgt bei
363    /// Bedarf — fuer Stufe 7 reicht die Submessage-Sequenz).
364    ///
365    /// # Errors
366    /// * `Crypto` durchgereicht.
367    /// * `PolicyViolation` wenn ein Peer-Key nicht registriert ist.
368    pub fn transform_outbound_group(
369        &self,
370        peer_keys: &[PeerKey],
371        plaintext: &[u8],
372    ) -> Result<Vec<u8>, SecurityGateError> {
373        let local = self.ensure_local()?;
374        // Resolve alle PeerKeys zu (CryptoHandle, key_id)-Paaren. Die
375        // key_id leiten wir deterministisch aus dem PeerKey-Prefix ab
376        // (low-32-bits des GuidPrefix) — beide Seiten (Sender +
377        // Empfaenger) koennen sie ohne zusaetzlichen Handshake
378        // berechnen. Caller muss vorher via register_remote_by_guid
379        // pro PeerKey einen Slot angelegt haben.
380        let bindings: Vec<(CryptoHandle, u32)> = self.with_inner(|g| {
381            let mut out = Vec::with_capacity(peer_keys.len());
382            for pk in peer_keys {
383                let h = g.peers.get(pk).copied().ok_or_else(|| {
384                    SecurityGateError::PolicyViolation(alloc::format!(
385                        "transform_outbound_group: peer {pk:?} not registered"
386                    ))
387                })?;
388                out.push((h, peer_key_to_id(pk)));
389            }
390            Ok(out)
391        })?;
392        self.with_inner(|g| {
393            encode_secured_submessage_multi(&*g.crypto, local, &bindings, plaintext)
394                .map_err(SecurityGateError::from)
395        })
396    }
397
398    /// Group-Inbound: dekodiert eine Multi-MAC-Submessage-Sequenz und
399    /// validiert **den eigenen** MAC.
400    ///
401    /// `sender_peer_key` ist der GuidPrefix des Senders (wie in
402    /// [`Self::register_remote_by_guid`] registriert). Der Empfaenger-
403    /// MAC-Key kommt aus `ensure_local()` — der Sender hat beim
404    /// Encoding genau diesen Slot-Handle als `remote_list`-Eintrag
405    /// gesetzt (via `register_remote_by_guid(our_prefix, our_local_token)`).
406    ///
407    /// # Errors
408    /// * `PolicyViolation` wenn `sender_peer_key` nicht registriert ist.
409    /// * `Crypto` / `Wrapper` bei Tag-/MAC-Mismatch.
410    pub fn transform_inbound_group(
411        &self,
412        sender_peer_key: &PeerKey,
413        own_peer_key: &PeerKey,
414        wire: &[u8],
415    ) -> Result<Vec<u8>, SecurityGateError> {
416        let sender_slot = self.slot_for(sender_peer_key)?.ok_or_else(|| {
417            SecurityGateError::PolicyViolation(alloc::format!(
418                "transform_inbound_group: unknown sender {sender_peer_key:?}"
419            ))
420        })?;
421        // Die MAC-Key-Slot-Quelle ist unser eigener local-Slot (der
422        // Sender hat bei der Registrierung unseres PeerKeys genau
423        // unseren local_token abgespeichert → gleicher Master-Key).
424        let own_local = self.ensure_local()?;
425        let own_id = peer_key_to_id(own_peer_key);
426        self.with_inner(|g| {
427            decode_secured_submessage_multi(
428                &*g.crypto,
429                sender_slot,
430                sender_slot,
431                own_id,
432                own_local,
433                wire,
434            )
435            .map_err(SecurityGateError::from)
436        })
437    }
438
439    /// Peer-spezifische Outbound-Transform.
440    ///
441    /// Im Gegensatz zu [`Self::transform_outbound`] ignoriert diese
442    /// Methode die Domain-Rule und wendet **stattdessen** das
443    /// Caller-gegebene [`ProtectionLevel`] an. Das ist die API, die
444    /// der heterogeneous-Security-Writer-Tick (Per-Reader-Serializer)
445    /// pro matched Reader aufruft.
446    ///
447    /// * `ProtectionLevel::None`    → plaintext passthrough
448    /// * `ProtectionLevel::Sign`    → RTPS-Message-wrap (HMAC/GCM-Tag)
449    /// * `ProtectionLevel::Encrypt` → RTPS-Message-wrap (AEAD-Ciphertext)
450    ///
451    /// Die Sign/Encrypt-Unterscheidung nutzt heute denselben Encoder
452    /// wie [`Self::transform_outbound`] — das aktuelle
453    /// `AesGcmCryptoPlugin` differenziert nicht. Receiver-Specific-MACs
454    /// und weitere Erweiterungen rüsten die Unterscheidung nach. Der
455    /// `peer_key` wird mitgefuehrt (fuer zukuenftige pro-Peer-Crypto-
456    /// Handles), aber noch nicht an den Plugin weitergereicht.
457    ///
458    /// # Errors
459    /// Siehe [`SecurityGateError`]. Im `None`-Pfad nie ein Fehler.
460    pub fn transform_outbound_for(
461        &self,
462        _peer_key: &PeerKey,
463        message: &[u8],
464        level: ProtectionLevel,
465    ) -> Result<Vec<u8>, SecurityGateError> {
466        match level {
467            ProtectionLevel::None => Ok(message.to_vec()),
468            ProtectionLevel::Sign | ProtectionLevel::Encrypt => {
469                let local = self.ensure_local()?;
470                self.with_inner(|g| {
471                    encode_secured_rtps_message(&*g.crypto, local, &[], message)
472                        .map_err(SecurityGateError::from)
473                })
474            }
475        }
476    }
477
478    /// Liefert das `allow_unauthenticated_participants`-Flag aus der
479    /// Domain-Rule. Default `false` wenn keine Rule fuer die Domain
480    /// existiert — konservativ-sichere Haltung.
481    pub fn allow_unauthenticated(&self) -> Result<bool, SecurityGateError> {
482        self.with_inner(|g| {
483            Ok(g.governance
484                .find_domain_rule(g.domain_id)
485                .map(|r| r.allow_unauthenticated_participants)
486                .unwrap_or(false))
487        })
488    }
489
490    /// Klassifiziert ein eingehendes RTPS-Datagram gegen Domain-Rule,
491    /// Peer-Registrierung, Wire-Format und Netzwerk-Interface
492    ///.
493    ///
494    /// Entscheidungs-Matrix:
495    ///
496    /// 1. `bytes.len() < 20` → `Malformed`.
497    /// 2. Extrahiere `peer_key` aus `bytes[8..20]`.
498    /// 3. Wenn Paket SRTPS-gewrappt ist → Standard-Unwrap-Pfad
499    ///    (`transform_inbound_from`). Bei Crypto-Fehler `CryptoError`,
500    ///    bei unbekannter Peer `PolicyViolation`.
501    /// 4. Wenn Paket plain ist UND Domain ProtectionKind::None verlangt
502    ///    → `Accept`.
503    /// 5. Wenn Paket plain ist UND Domain Protection verlangt:
504    ///    * Interface ist `Loopback` oder `LocalHost` → `Accept`
505    ///      (Bytes verlassen den Host-Kernel nicht — spec-konform
506    ///      plaintext auf Host-local Transport)
507    ///    * `allow_unauthenticated_participants = true` → `Accept`
508    ///    * sonst → `LegacyBlocked`
509    ///
510    /// Der `iface`-Kontext wird derzeit in den Regeln nur fuer den
511    /// Loopback-Fast-Path konsultiert; die feinere Peer-/Topic-
512    /// Klassifizierung pro Interface uebernimmt die `PolicyEngine`
513    /// ab Stufe 8 (Governance-XML `<interface_bindings>`).
514    #[must_use]
515    pub fn classify_inbound(&self, bytes: &[u8], iface: &NetInterface) -> InboundVerdict {
516        if bytes.len() < RTPS_HEADER_LEN + 8 {
517            return InboundVerdict::Malformed;
518        }
519        let mut peer_key = [0u8; 12];
520        peer_key.copy_from_slice(&bytes[8..20]);
521
522        let looks_secured = bytes.len() > RTPS_HEADER_LEN && bytes[RTPS_HEADER_LEN] == SRTPS_PREFIX;
523        let kind = match self.message_protection() {
524            Ok(k) => k,
525            Err(e) => {
526                return InboundVerdict::CryptoError(alloc::format!("gate lookup failed: {e:?}"));
527            }
528        };
529
530        if looks_secured {
531            return match self.transform_inbound_from(&peer_key, bytes) {
532                Ok(clear) => InboundVerdict::Accept(clear),
533                Err(SecurityGateError::PolicyViolation(msg)) => {
534                    InboundVerdict::PolicyViolation(msg)
535                }
536                Err(e) => InboundVerdict::CryptoError(alloc::format!("{e:?}")),
537            };
538        }
539
540        // Plain-Paket kam rein.
541        if matches!(kind, ProtectionKind::None) {
542            return InboundVerdict::Accept(bytes.to_vec());
543        }
544        // Loopback / LocalHost: Bytes verlassen den Host nicht, also
545        // ist plain fachlich OK — passt zum Arch-Doc §2.1 "Intra-
546        // Host-Loopback: Plain (kein Netz verlassen)".
547        if matches!(iface, NetInterface::Loopback | NetInterface::LocalHost) {
548            return InboundVerdict::Accept(bytes.to_vec());
549        }
550        // Domain verlangt Schutz, Peer hat plain auf einem Remote-
551        // Interface geschickt.
552        match self.allow_unauthenticated() {
553            Ok(true) => InboundVerdict::Accept(bytes.to_vec()),
554            Ok(false) => InboundVerdict::LegacyBlocked,
555            Err(e) => InboundVerdict::CryptoError(alloc::format!("gate lookup failed: {e:?}")),
556        }
557    }
558
559    /// Inbound-Message: unwrap wenn SRTPS_PREFIX erkannt, sonst
560    /// passthrough oder PolicyViolation.
561    ///
562    /// `remote_slot` zeigt auf den Slot, in dem der Sender-Key
563    /// abgelegt ist (aus [`Self::register_remote_with_token`]).
564    ///
565    /// # Errors
566    /// Siehe [`SecurityGateError`].
567    pub fn transform_inbound(
568        &self,
569        remote_slot: CryptoHandle,
570        wire: &[u8],
571    ) -> Result<Vec<u8>, SecurityGateError> {
572        let looks_secured = wire.len() > RTPS_HEADER_LEN && wire[RTPS_HEADER_LEN] == SRTPS_PREFIX;
573        let kind = self.message_protection()?;
574        match (kind, looks_secured) {
575            (ProtectionKind::None, false) => Ok(wire.to_vec()),
576            (_, true) => self.with_inner(|g| {
577                decode_secured_rtps_message(&*g.crypto, remote_slot, remote_slot, wire)
578                    .map_err(SecurityGateError::from)
579            }),
580            (_, false) => Err(SecurityGateError::PolicyViolation(alloc::format!(
581                "domain verlangt {kind:?}, bekam plain-rtps-message"
582            ))),
583        }
584    }
585}
586
587#[cfg(test)]
588#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
589mod tests {
590    use super::*;
591    use std::thread;
592    use zerodds_security_crypto::AesGcmCryptoPlugin;
593    use zerodds_security_permissions::parse_governance_xml;
594
595    const GOV_RTPS: &str = r#"
596<domain_access_rules>
597  <domain_rule>
598    <domains><id>0</id></domains>
599    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
600    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
601  </domain_rule>
602</domain_access_rules>
603"#;
604
605    fn fake_msg(body: &[u8]) -> Vec<u8> {
606        let mut m = Vec::with_capacity(20 + body.len());
607        m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
608        m.extend_from_slice(&[0u8; 12]);
609        m.extend_from_slice(body);
610        m
611    }
612
613    #[test]
614    fn outbound_none_is_passthrough() {
615        let gov = parse_governance_xml(
616            r#"<domain_access_rules><domain_rule><domains><id>0</id></domains><topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules></domain_rule></domain_access_rules>"#,
617        )
618        .unwrap();
619        let gate = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
620        let msg = fake_msg(b"x");
621        assert_eq!(gate.transform_outbound(&msg).unwrap(), msg);
622    }
623
624    #[test]
625    fn e2e_alice_bob_with_shared_gate() {
626        let alice = SharedSecurityGate::new(
627            0,
628            parse_governance_xml(GOV_RTPS).unwrap(),
629            Box::new(AesGcmCryptoPlugin::new()),
630        );
631        let bob = SharedSecurityGate::new(
632            0,
633            parse_governance_xml(GOV_RTPS).unwrap(),
634            Box::new(AesGcmCryptoPlugin::new()),
635        );
636        let alice_token = alice.local_token().unwrap();
637        let bob_view_of_alice = bob
638            .register_remote_with_token(IdentityHandle(1), SharedSecretHandle(1), &alice_token)
639            .unwrap();
640
641        let plain = fake_msg(b"[DATA:shared]");
642        let wire = alice.transform_outbound(&plain).unwrap();
643        let back = bob.transform_inbound(bob_view_of_alice, &wire).unwrap();
644        assert_eq!(back, plain);
645    }
646
647    #[test]
648    fn clone_shares_same_plugin_instance() {
649        // Clone erzeugt einen zweiten Gate-Handle auf DAS SELBE Plugin.
650        // `ensure_local` durch clone1 legt den local-Slot an; clone2
651        // sieht dieselbe Session-ID.
652        let gate1 = SharedSecurityGate::new(
653            0,
654            parse_governance_xml(GOV_RTPS).unwrap(),
655            Box::new(AesGcmCryptoPlugin::new()),
656        );
657        let gate2 = gate1.clone();
658        let t1 = gate1.local_token().unwrap();
659        let t2 = gate2.local_token().unwrap();
660        assert_eq!(t1, t2, "beide Clones sehen den gleichen lokalen Slot");
661    }
662
663    #[test]
664    fn concurrent_transform_is_thread_safe() {
665        let alice = SharedSecurityGate::new(
666            0,
667            parse_governance_xml(GOV_RTPS).unwrap(),
668            Box::new(AesGcmCryptoPlugin::new()),
669        );
670        let mut handles = Vec::new();
671        for i in 0..8 {
672            let g = alice.clone();
673            handles.push(thread::spawn(move || {
674                let m = fake_msg(alloc::format!("[DATA:{i}]").as_bytes());
675                // Muss OHNE Panic serialisieren — Nonce-Counter bleibt
676                // thread-safe (AtomicU64 im KeyMaterial).
677                let _ = g.transform_outbound(&m).unwrap();
678            }));
679        }
680        for h in handles {
681            h.join().unwrap();
682        }
683    }
684
685    #[test]
686    fn plain_inbound_on_protected_domain_is_policy_violation() {
687        let gate = SharedSecurityGate::new(
688            0,
689            parse_governance_xml(GOV_RTPS).unwrap(),
690            Box::new(AesGcmCryptoPlugin::new()),
691        );
692        let plain = fake_msg(b"nope");
693        let err = gate
694            .transform_inbound(CryptoHandle(99), &plain)
695            .unwrap_err();
696        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
697    }
698
699    #[test]
700    fn domain_id_reflects_constructor() {
701        let gate = SharedSecurityGate::new(
702            7,
703            parse_governance_xml(GOV_RTPS).unwrap(),
704            Box::new(AesGcmCryptoPlugin::new()),
705        );
706        assert_eq!(gate.domain_id().unwrap(), 7);
707    }
708
709    // -------------------------------------------------------------
710    // RC1.4 Vorbereitung — Peer-Key-Mapping
711    // -------------------------------------------------------------
712
713    fn build_pair() -> (SharedSecurityGate, SharedSecurityGate) {
714        let alice = SharedSecurityGate::new(
715            0,
716            parse_governance_xml(GOV_RTPS).unwrap(),
717            Box::new(AesGcmCryptoPlugin::new()),
718        );
719        let bob = SharedSecurityGate::new(
720            0,
721            parse_governance_xml(GOV_RTPS).unwrap(),
722            Box::new(AesGcmCryptoPlugin::new()),
723        );
724        (alice, bob)
725    }
726
727    #[test]
728    fn register_remote_by_guid_is_idempotent() {
729        let (alice, bob) = build_pair();
730        let alice_prefix: PeerKey = [0xAA; 12];
731        let atoken = alice.local_token().unwrap();
732        let slot1 = bob
733            .register_remote_by_guid(
734                alice_prefix,
735                IdentityHandle(1),
736                SharedSecretHandle(1),
737                &atoken,
738            )
739            .unwrap();
740        let slot2 = bob
741            .register_remote_by_guid(
742                alice_prefix,
743                IdentityHandle(1),
744                SharedSecretHandle(1),
745                &atoken,
746            )
747            .unwrap();
748        assert_eq!(
749            slot1, slot2,
750            "idempotent: gleicher guid-prefix → gleicher slot"
751        );
752    }
753
754    #[test]
755    fn transform_inbound_from_looks_up_slot_by_guid() {
756        let (alice, bob) = build_pair();
757        let alice_prefix: PeerKey = [0xAA; 12];
758        let atoken = alice.local_token().unwrap();
759        bob.register_remote_by_guid(
760            alice_prefix,
761            IdentityHandle(1),
762            SharedSecretHandle(1),
763            &atoken,
764        )
765        .unwrap();
766
767        let msg = fake_msg(b"[DATA:guid-lookup]");
768        let wire = alice.transform_outbound(&msg).unwrap();
769        let back = bob.transform_inbound_from(&alice_prefix, &wire).unwrap();
770        assert_eq!(back, msg);
771    }
772
773    #[test]
774    fn transform_inbound_from_unknown_peer_is_policy_violation() {
775        let (alice, bob) = build_pair();
776        // Alice registriert sich NICHT bei Bob.
777        let msg = fake_msg(b"x");
778        let wire = alice.transform_outbound(&msg).unwrap();
779        let err = bob.transform_inbound_from(&[0xCC; 12], &wire).unwrap_err();
780        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
781    }
782
783    #[test]
784    fn multi_peer_mapping_routes_correctly() {
785        // Alice + Charlie senden an Bob. Bob muss die beiden per
786        // GuidPrefix unterscheiden.
787        let gov = parse_governance_xml(GOV_RTPS).unwrap();
788        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
789        let charlie = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
790        let bob = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
791
792        let alice_prefix: PeerKey = [1u8; 12];
793        let charlie_prefix: PeerKey = [3u8; 12];
794
795        bob.register_remote_by_guid(
796            alice_prefix,
797            IdentityHandle(1),
798            SharedSecretHandle(1),
799            &alice.local_token().unwrap(),
800        )
801        .unwrap();
802        bob.register_remote_by_guid(
803            charlie_prefix,
804            IdentityHandle(3),
805            SharedSecretHandle(3),
806            &charlie.local_token().unwrap(),
807        )
808        .unwrap();
809
810        let m_alice = fake_msg(b"from-alice");
811        let m_charlie = fake_msg(b"from-charlie");
812        let w_alice = alice.transform_outbound(&m_alice).unwrap();
813        let w_charlie = charlie.transform_outbound(&m_charlie).unwrap();
814
815        assert_eq!(
816            bob.transform_inbound_from(&alice_prefix, &w_alice).unwrap(),
817            m_alice
818        );
819        assert_eq!(
820            bob.transform_inbound_from(&charlie_prefix, &w_charlie)
821                .unwrap(),
822            m_charlie
823        );
824    }
825
826    #[test]
827    fn wrong_prefix_fails_tag_verify() {
828        // Alice schickt, Bob dekodiert mit Charlie's Slot → Tag-
829        // Mismatch.
830        let (alice, bob) = build_pair();
831        let gov = parse_governance_xml(GOV_RTPS).unwrap();
832        let charlie = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
833
834        let alice_prefix: PeerKey = [1u8; 12];
835        let charlie_prefix: PeerKey = [3u8; 12];
836        bob.register_remote_by_guid(
837            alice_prefix,
838            IdentityHandle(1),
839            SharedSecretHandle(1),
840            &alice.local_token().unwrap(),
841        )
842        .unwrap();
843        bob.register_remote_by_guid(
844            charlie_prefix,
845            IdentityHandle(3),
846            SharedSecretHandle(3),
847            &charlie.local_token().unwrap(),
848        )
849        .unwrap();
850
851        let msg = fake_msg(b"from-alice");
852        let wire = alice.transform_outbound(&msg).unwrap();
853        // Bob dekodiert mit Charlie's Prefix → Crypto-Error.
854        let err = bob
855            .transform_inbound_from(&charlie_prefix, &wire)
856            .unwrap_err();
857        assert!(matches!(
858            err,
859            SecurityGateError::Wrapper(_) | SecurityGateError::Crypto(_)
860        ));
861    }
862
863    // -------------------------------------------------------------
864    // RC1 Stufe 7 — Receiver-Specific-MACs im Gate-E2E
865    // -------------------------------------------------------------
866
867    #[test]
868    fn group_transform_one_ciphertext_three_macs_each_reader_decodes() {
869        // DoD §Stufe 7 wortwoertlich: 1 Writer, 3 Reader gleiche Suite,
870        // unterschiedliche Tokens → ein Ciphertext + 3 MACs; jeder
871        // Reader validiert seinen eigenen MAC.
872        let gov = parse_governance_xml(GOV_RTPS).unwrap();
873        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
874        let bob = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
875        let charlie = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
876        let dave = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
877
878        let bob_prefix: PeerKey = [0xB1; 12];
879        let charlie_prefix: PeerKey = [0xC1; 12];
880        let dave_prefix: PeerKey = [0xD1; 12];
881        let alice_prefix: PeerKey = [0xA1; 12];
882
883        // Alice registriert alle drei Receiver mit ihren **eigenen**
884        // Session-Keys (das ist das pro-Reader-SharedSecret).
885        alice
886            .register_remote_by_guid(
887                bob_prefix,
888                IdentityHandle(1),
889                SharedSecretHandle(1),
890                &bob.local_token().unwrap(),
891            )
892            .unwrap();
893        alice
894            .register_remote_by_guid(
895                charlie_prefix,
896                IdentityHandle(2),
897                SharedSecretHandle(2),
898                &charlie.local_token().unwrap(),
899            )
900            .unwrap();
901        alice
902            .register_remote_by_guid(
903                dave_prefix,
904                IdentityHandle(3),
905                SharedSecretHandle(3),
906                &dave.local_token().unwrap(),
907            )
908            .unwrap();
909
910        // Jeder Receiver registriert Alice unter ihrem Alice-Prefix —
911        // damit `transform_inbound_group` die Sender-Seite findet
912        // fuer den AES-GCM-Unwrap.
913        for recv in [&bob, &charlie, &dave] {
914            recv.register_remote_by_guid(
915                alice_prefix,
916                IdentityHandle(10),
917                SharedSecretHandle(10),
918                &alice.local_token().unwrap(),
919            )
920            .unwrap();
921        }
922
923        // Encode: 1 Ciphertext + 3 MACs.
924        let plain = b"hetero-broadcast-e2e";
925        let wire = alice
926            .transform_outbound_group(&[bob_prefix, charlie_prefix, dave_prefix], plain)
927            .unwrap();
928
929        // Jeder Receiver decodiert seine Variante identisch — mit
930        // seinem eigenen PeerKey als Match-ID.
931        let out_bob = bob
932            .transform_inbound_group(&alice_prefix, &bob_prefix, &wire)
933            .unwrap();
934        let out_charlie = charlie
935            .transform_inbound_group(&alice_prefix, &charlie_prefix, &wire)
936            .unwrap();
937        let out_dave = dave
938            .transform_inbound_group(&alice_prefix, &dave_prefix, &wire)
939            .unwrap();
940        assert_eq!(out_bob, plain);
941        assert_eq!(out_charlie, plain);
942        assert_eq!(out_dave, plain);
943    }
944
945    #[test]
946    fn group_transform_rogue_receiver_without_mac_rejects() {
947        // Ein 4. Receiver (Eve) ist bei Alice NICHT in der MAC-Liste.
948        // Eve hat Alice's Token via Seitenkanal bekommen (oder
949        // mitgelauscht) und versucht zu decodieren. Der eigene HMAC-
950        // Key in Eve's `ensure_local()`-Slot matcht keinen MAC-
951        // Eintrag → Crypto-Fail.
952        let gov = parse_governance_xml(GOV_RTPS).unwrap();
953        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
954        let bob = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
955        let eve = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
956
957        let alice_prefix: PeerKey = [0xA2; 12];
958        let bob_prefix: PeerKey = [0xB2; 12];
959        alice
960            .register_remote_by_guid(
961                bob_prefix,
962                IdentityHandle(1),
963                SharedSecretHandle(1),
964                &bob.local_token().unwrap(),
965            )
966            .unwrap();
967        // Eve kennt Alice's Token (Angreifer-Szenario), registriert sie.
968        eve.register_remote_by_guid(
969            alice_prefix,
970            IdentityHandle(10),
971            SharedSecretHandle(10),
972            &alice.local_token().unwrap(),
973        )
974        .unwrap();
975
976        let wire = alice
977            .transform_outbound_group(&[bob_prefix], b"confidential")
978            .unwrap();
979
980        let eve_prefix: PeerKey = [0xEE; 12];
981        let err = eve
982            .transform_inbound_group(&alice_prefix, &eve_prefix, &wire)
983            .unwrap_err();
984        assert!(
985            matches!(
986                err,
987                SecurityGateError::Crypto(_) | SecurityGateError::Wrapper(_)
988            ),
989            "Eve ohne MAC-Entry muss droppen, got: {err:?}"
990        );
991    }
992
993    #[test]
994    fn group_transform_unknown_peer_is_policy_violation() {
995        // Alice versucht fuer einen nicht-registrierten Peer zu
996        // encoden → PolicyViolation.
997        let alice = SharedSecurityGate::new(
998            0,
999            parse_governance_xml(GOV_RTPS).unwrap(),
1000            Box::new(AesGcmCryptoPlugin::new()),
1001        );
1002        let unregistered: PeerKey = [0x99; 12];
1003        let err = alice
1004            .transform_outbound_group(&[unregistered], b"x")
1005            .unwrap_err();
1006        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
1007    }
1008
1009    #[test]
1010    fn forget_remote_removes_mapping() {
1011        let (alice, bob) = build_pair();
1012        let alice_prefix: PeerKey = [0xAA; 12];
1013        bob.register_remote_by_guid(
1014            alice_prefix,
1015            IdentityHandle(1),
1016            SharedSecretHandle(1),
1017            &alice.local_token().unwrap(),
1018        )
1019        .unwrap();
1020        assert!(bob.slot_for(&alice_prefix).unwrap().is_some());
1021        bob.forget_remote(&alice_prefix).unwrap();
1022        assert!(bob.slot_for(&alice_prefix).unwrap().is_none());
1023    }
1024
1025    // -------------------------------------------------------------
1026    // RC1 Stufe 4a — transform_outbound_for
1027    // -------------------------------------------------------------
1028
1029    #[test]
1030    fn transform_outbound_for_none_is_passthrough_even_on_protected_domain() {
1031        // Domain ist ENCRYPT per Governance, aber Caller verlangt
1032        // per-Reader None — muss plaintext liefern (das ist der
1033        // Heterogeneous-Fall).
1034        let gate = SharedSecurityGate::new(
1035            0,
1036            parse_governance_xml(GOV_RTPS).unwrap(),
1037            Box::new(AesGcmCryptoPlugin::new()),
1038        );
1039        let peer_key: PeerKey = [0xBB; 12];
1040        let msg = fake_msg(b"[plain-for-legacy]");
1041        let out = gate
1042            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::None)
1043            .unwrap();
1044        assert_eq!(out, msg, "None-Level muss byte-identisch passthrough sein");
1045    }
1046
1047    #[test]
1048    fn transform_outbound_for_encrypt_produces_srtps_wire() {
1049        let gate = SharedSecurityGate::new(
1050            0,
1051            parse_governance_xml(GOV_RTPS).unwrap(),
1052            Box::new(AesGcmCryptoPlugin::new()),
1053        );
1054        let peer_key: PeerKey = [0xCC; 12];
1055        let msg = fake_msg(b"[enc-for-secure]");
1056        let wire = gate
1057            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::Encrypt)
1058            .unwrap();
1059        // Output muss laenger als plain sein (SRTPS-Overhead) und beim
1060        // SRTPS_PREFIX-Byte nach dem RTPS-Header starten.
1061        assert!(wire.len() > msg.len());
1062        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
1063    }
1064
1065    #[test]
1066    fn transform_outbound_for_sign_also_uses_srtps_encoder() {
1067        // Sign-Level nutzt heute denselben Encoder wie Encrypt (v1.4-
1068        // Plugin-Status). Wichtig ist nur: Output ist KEIN plaintext.
1069        let gate = SharedSecurityGate::new(
1070            0,
1071            parse_governance_xml(GOV_RTPS).unwrap(),
1072            Box::new(AesGcmCryptoPlugin::new()),
1073        );
1074        let peer_key: PeerKey = [0xDD; 12];
1075        let msg = fake_msg(b"[sig-for-fast]");
1076        let wire = gate
1077            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::Sign)
1078            .unwrap();
1079        assert_ne!(wire, msg, "Sign darf nicht byte-identisch zu plain sein");
1080        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
1081    }
1082
1083    #[test]
1084    fn transform_outbound_for_heterogeneous_three_readers() {
1085        // 1 Writer → 3 Reader (legacy/sign/encrypt). Jedes Output ist
1086        // individuell verschieden — das ist der Kern von RC1.
1087        let gate = SharedSecurityGate::new(
1088            0,
1089            parse_governance_xml(GOV_RTPS).unwrap(),
1090            Box::new(AesGcmCryptoPlugin::new()),
1091        );
1092        let msg = fake_msg(b"[broadcast]");
1093        let legacy = gate
1094            .transform_outbound_for(&[1; 12], &msg, ProtectionLevel::None)
1095            .unwrap();
1096        let fast = gate
1097            .transform_outbound_for(&[2; 12], &msg, ProtectionLevel::Sign)
1098            .unwrap();
1099        let secure = gate
1100            .transform_outbound_for(&[3; 12], &msg, ProtectionLevel::Encrypt)
1101            .unwrap();
1102        assert_eq!(legacy, msg, "Legacy-Reader bekommt plain");
1103        assert_ne!(fast, msg, "Fast-Reader bekommt SRTPS-wrapped");
1104        assert_ne!(secure, msg, "Secure-Reader bekommt SRTPS-wrapped");
1105        // Sign- und Encrypt-Pakete sind nicht byte-identisch — auch
1106        // wenn derselbe Encoder genutzt wird, nutzt jedes encode einen
1107        // frischen Nonce-Counter.
1108        assert_ne!(fast, secure, "Per-Reader-Encoding muss je verschieden sein");
1109    }
1110
1111    // -------------------------------------------------------------
1112    // RC1 Stufe 5 — classify_inbound + allow_unauthenticated
1113    // -------------------------------------------------------------
1114
1115    const GOV_NONE: &str = r#"
1116<domain_access_rules>
1117  <domain_rule>
1118    <domains><id>0</id></domains>
1119    <rtps_protection_kind>NONE</rtps_protection_kind>
1120    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
1121  </domain_rule>
1122</domain_access_rules>
1123"#;
1124    const GOV_ENCRYPT_ALLOW_UNAUTH: &str = r#"
1125<domain_access_rules>
1126  <domain_rule>
1127    <domains><id>0</id></domains>
1128    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
1129    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
1130    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
1131  </domain_rule>
1132</domain_access_rules>
1133"#;
1134
1135    #[test]
1136    fn allow_unauthenticated_default_false_without_element() {
1137        let gate = SharedSecurityGate::new(
1138            0,
1139            parse_governance_xml(GOV_RTPS).unwrap(),
1140            Box::new(AesGcmCryptoPlugin::new()),
1141        );
1142        assert!(!gate.allow_unauthenticated().unwrap());
1143    }
1144
1145    #[test]
1146    fn allow_unauthenticated_reads_true_when_set() {
1147        let gate = SharedSecurityGate::new(
1148            0,
1149            parse_governance_xml(GOV_ENCRYPT_ALLOW_UNAUTH).unwrap(),
1150            Box::new(AesGcmCryptoPlugin::new()),
1151        );
1152        assert!(gate.allow_unauthenticated().unwrap());
1153    }
1154
1155    #[test]
1156    fn allow_unauthenticated_defaults_false_for_unknown_domain() {
1157        let gate = SharedSecurityGate::new(
1158            99,
1159            parse_governance_xml(GOV_RTPS).unwrap(),
1160            Box::new(AesGcmCryptoPlugin::new()),
1161        );
1162        assert!(!gate.allow_unauthenticated().unwrap());
1163    }
1164
1165    #[test]
1166    fn classify_inbound_rejects_truncated_datagram() {
1167        let gate = SharedSecurityGate::new(
1168            0,
1169            parse_governance_xml(GOV_NONE).unwrap(),
1170            Box::new(AesGcmCryptoPlugin::new()),
1171        );
1172        let verdict = gate.classify_inbound(&[0u8; 10], &NetInterface::Wan);
1173        assert_eq!(verdict, InboundVerdict::Malformed);
1174        assert_eq!(verdict.category(), "inbound.malformed");
1175    }
1176
1177    #[test]
1178    fn classify_inbound_plain_on_none_domain_accepts() {
1179        let gate = SharedSecurityGate::new(
1180            0,
1181            parse_governance_xml(GOV_NONE).unwrap(),
1182            Box::new(AesGcmCryptoPlugin::new()),
1183        );
1184        let msg = fake_msg(b"[plain-hello]");
1185        match gate.classify_inbound(&msg, &NetInterface::Wan) {
1186            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1187            other => panic!("expected Accept, got {other:?}"),
1188        }
1189    }
1190
1191    #[test]
1192    fn classify_inbound_plain_on_protected_domain_is_legacy_blocked() {
1193        // Domain verlangt ENCRYPT, allow_unauth = false (default).
1194        let gate = SharedSecurityGate::new(
1195            0,
1196            parse_governance_xml(GOV_RTPS).unwrap(),
1197            Box::new(AesGcmCryptoPlugin::new()),
1198        );
1199        let msg = fake_msg(b"[legacy-on-encrypted]");
1200        let verdict = gate.classify_inbound(&msg, &NetInterface::Wan);
1201        assert_eq!(verdict, InboundVerdict::LegacyBlocked);
1202        assert_eq!(verdict.category(), "inbound.legacy_blocked");
1203        assert!(!verdict.is_accept());
1204    }
1205
1206    #[test]
1207    fn classify_inbound_plain_on_protected_domain_with_allow_unauth_accepts() {
1208        // DoD-Test: Legacy-Peer wird akzeptiert wenn Governance das
1209        // explizit zulaesst.
1210        let gate = SharedSecurityGate::new(
1211            0,
1212            parse_governance_xml(GOV_ENCRYPT_ALLOW_UNAUTH).unwrap(),
1213            Box::new(AesGcmCryptoPlugin::new()),
1214        );
1215        let msg = fake_msg(b"[legacy-allowed]");
1216        match gate.classify_inbound(&msg, &NetInterface::Wan) {
1217            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1218            other => panic!("expected Accept (allow_unauthenticated=true), got {other:?}"),
1219        }
1220    }
1221
1222    #[test]
1223    fn classify_inbound_plain_on_loopback_accepts_even_on_protected_domain() {
1224        // Arch-Doc §2.1: "Intra-Host-Loopback: Plain (kein Netz
1225        // verlassen)". Protected Domain, aber Interface=Loopback →
1226        // plaintext ist spec-konform akzeptiert.
1227        let gate = SharedSecurityGate::new(
1228            0,
1229            parse_governance_xml(GOV_RTPS).unwrap(),
1230            Box::new(AesGcmCryptoPlugin::new()),
1231        );
1232        let msg = fake_msg(b"[loopback-plain]");
1233        match gate.classify_inbound(&msg, &NetInterface::Loopback) {
1234            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1235            other => panic!("expected Loopback-Accept, got {other:?}"),
1236        }
1237    }
1238
1239    #[test]
1240    fn classify_inbound_srtps_from_unknown_peer_is_policy_violation() {
1241        // Kein register_remote_by_guid — Peer ist unbekannt.
1242        let (alice, bob) = build_pair();
1243        // alice sendet uns einen SRTPS-Wrapper, aber bob hat alice
1244        // nicht registriert → classify muss PolicyViolation melden.
1245        let msg = fake_msg(b"[from-unknown]");
1246        let wire = alice.transform_outbound(&msg).unwrap();
1247        let verdict = bob.classify_inbound(&wire, &NetInterface::Wan);
1248        assert!(
1249            matches!(verdict, InboundVerdict::PolicyViolation(_)),
1250            "expected PolicyViolation, got {verdict:?}"
1251        );
1252        assert_eq!(verdict.category(), "inbound.policy_violation");
1253    }
1254
1255    #[test]
1256    fn classify_inbound_srtps_from_known_peer_accepts() {
1257        let (alice, bob) = build_pair();
1258        let alice_prefix: PeerKey = [0xAA; 12];
1259        bob.register_remote_by_guid(
1260            alice_prefix,
1261            IdentityHandle(1),
1262            SharedSecretHandle(1),
1263            &alice.local_token().unwrap(),
1264        )
1265        .unwrap();
1266        let msg = fake_msg(b"[authed-peer]");
1267        // Sender muss den gleichen GuidPrefix im Header tragen, damit
1268        // classify_inbound den Peer-Key findet.
1269        let mut hdr_msg = Vec::with_capacity(msg.len());
1270        hdr_msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
1271        hdr_msg.extend_from_slice(&alice_prefix);
1272        hdr_msg.extend_from_slice(b"payload-body");
1273        let wire = alice.transform_outbound(&hdr_msg).unwrap();
1274        match bob.classify_inbound(&wire, &NetInterface::Wan) {
1275            InboundVerdict::Accept(_) => {}
1276            other => panic!("expected Accept, got {other:?}"),
1277        }
1278    }
1279
1280    #[test]
1281    fn classify_inbound_srtps_with_wrong_key_is_crypto_error() {
1282        // Alice + Charlie encoden mit verschiedenen Keys; Bob hat
1283        // Alice registriert aber kriegt Charlie's Bytes unter Alice's
1284        // peer_key → Crypto-Tag-Mismatch.
1285        let (alice, bob) = build_pair();
1286        let gov = parse_governance_xml(GOV_RTPS).unwrap();
1287        let charlie = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1288
1289        let alice_prefix: PeerKey = [0xAA; 12];
1290        bob.register_remote_by_guid(
1291            alice_prefix,
1292            IdentityHandle(1),
1293            SharedSecretHandle(1),
1294            &alice.local_token().unwrap(),
1295        )
1296        .unwrap();
1297
1298        // Charlie encodet mit Alice's prefix im Header (MITM-Simulation).
1299        let mut body = Vec::new();
1300        body.extend_from_slice(b"RTPS\x02\x05\x01\x02");
1301        body.extend_from_slice(&alice_prefix);
1302        body.extend_from_slice(b"mitm-try");
1303        let spoofed = charlie.transform_outbound(&body).unwrap();
1304
1305        let verdict = bob.classify_inbound(&spoofed, &NetInterface::Wan);
1306        assert!(
1307            matches!(verdict, InboundVerdict::CryptoError(_)),
1308            "expected CryptoError, got {verdict:?}"
1309        );
1310        assert_eq!(verdict.category(), "inbound.crypto_error");
1311    }
1312
1313    #[test]
1314    fn transform_outbound_for_is_decodable_with_registered_token() {
1315        // E2E: Alice serialisiert per `transform_outbound_for`, Bob
1316        // registriert Alice's Token und dekodiert erfolgreich.
1317        let (alice, bob) = build_pair();
1318        let alice_prefix: PeerKey = [0xAA; 12];
1319        bob.register_remote_by_guid(
1320            alice_prefix,
1321            IdentityHandle(1),
1322            SharedSecretHandle(1),
1323            &alice.local_token().unwrap(),
1324        )
1325        .unwrap();
1326        let msg = fake_msg(b"[hetero-e2e]");
1327        let wire = alice
1328            .transform_outbound_for(&[9; 12], &msg, ProtectionLevel::Encrypt)
1329            .unwrap();
1330        let back = bob.transform_inbound_from(&alice_prefix, &wire).unwrap();
1331        assert_eq!(back, msg);
1332    }
1333}