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 around [`SecurityGate`] for multi-thread use.
5//!
6//! The reference-based `SecurityGate<'c, P>` is meant for single-thread
7//! use — but several runtime threads (SPDP loop, user RX,
8//! event loop) need synchronized access.
9//!
10//! [`SharedSecurityGate`] encapsulates:
11//! * `Governance` (immutable per participant — cloneable).
12//! * `Box<dyn CryptographicPlugin>` (mutable on key registration).
13//! * Cache of the local CryptoHandle.
14//!
15//! zerodds-lint: allow no_dyn_in_safe
16//! (the plugin is held via `Box<dyn CryptographicPlugin>` so that
17//! the user can freely choose the backend.)
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_submessage_multi, encode_secured_submessage_multi,
29};
30
31use crate::gate::SecurityGateError;
32use crate::policy::{NetInterface, ProtectionLevel};
33
34// ============================================================================
35// Inbound verdict
36// ============================================================================
37
38/// Result of a `classify_inbound` decision.
39///
40/// The enum variants cleanly separate the possible reasons, so the
41/// caller (dcps runtime) can pass a suitable `LogLevel` per reason to the
42/// [`zerodds_security::logging::LoggingPlugin`].
43///
44/// The interface context (`NetInterface`) is passed along by the caller
45/// and reappears in [`InboundVerdict::iface`] —
46/// so log events are attributable per interface.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum InboundVerdict {
49    /// The packet is admissible — `bytes` is the decoded RTPS datagram
50    /// passed on to the SPDP/SEDP/user dispatch.
51    Accept(Vec<u8>),
52    /// The packet is too short for an RTPS header (< 20 bytes). This is
53    /// really a transport bug or a fuzz probe. Severity
54    /// should be `Error`.
55    Malformed,
56    /// The packet came from an **unauth peer on a domain that
57    /// requires authentication** (`allow_unauthenticated_participants =
58    /// false`). Severity should be `Error`.
59    LegacyBlocked,
60    /// Policy violation: the domain requires protection but the packet
61    /// is plain (or vice versa). Severity should be `Warning`
62    /// — possibly a tampering attempt.
63    PolicyViolation(String),
64    /// Cryptographic error on unwrap (tag mismatch, wrong
65    /// key, replay attack, etc.). Severity `Warning`.
66    CryptoError(String),
67}
68
69impl InboundVerdict {
70    /// Shorthand: `true` if the packet is passed on.
71    #[must_use]
72    pub const fn is_accept(&self) -> bool {
73        matches!(self, Self::Accept(_))
74    }
75
76    /// Log category (OMG §8.6.3) — free string that identifies the drop
77    /// reason. Helpful when a LoggingPlugin filters by category.
78    #[must_use]
79    pub fn category(&self) -> &'static str {
80        match self {
81            Self::Accept(_) => "inbound.accept",
82            Self::Malformed => "inbound.malformed",
83            Self::LegacyBlocked => "inbound.legacy_blocked",
84            Self::PolicyViolation(_) => "inbound.policy_violation",
85            Self::CryptoError(_) => "inbound.crypto_error",
86        }
87    }
88}
89
90/// Opaque peer identifier. In RTPS environments the caller typically maps
91/// `GuidPrefix` (12 bytes) onto it — `[u8; 12]` fits exactly.
92pub type PeerKey = [u8; 12];
93
94/// Thread-safe security gate. Clone gives a second reference to
95/// the same plugin instance — all clones operate on the same
96/// key store.
97#[derive(Clone)]
98pub struct SharedSecurityGate {
99    inner: Arc<Mutex<GateInner>>,
100}
101
102impl core::fmt::Debug for SharedSecurityGate {
103    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104        // Plugin + keys never in debug output — only metadata.
105        match self.inner.lock() {
106            Ok(g) => write!(
107                f,
108                "SharedSecurityGate {{ domain_id: {}, peers: {}, local_registered: {} }}",
109                g.domain_id,
110                g.peers.len(),
111                g.local.is_some()
112            ),
113            Err(_) => write!(f, "SharedSecurityGate {{ <poisoned> }}"),
114        }
115    }
116}
117
118struct GateInner {
119    domain_id: u32,
120    governance: Governance,
121    crypto: Box<dyn CryptographicPlugin>,
122    local: Option<CryptoHandle>,
123    /// Peer-key → CryptoHandle mapping. Filled by
124    /// `register_remote_by_guid`; `transform_inbound_from`
125    /// looks here.
126    peers: BTreeMap<PeerKey, CryptoHandle>,
127}
128
129/// Derives a deterministic 4-byte `key_id` from a
130/// 12-byte [`PeerKey`] (GuidPrefix). Both communication partners
131/// compute it identically without a handshake, i.e. the `key_id` in the
132/// wire-format SEC_POSTFIX is synchronizable across
133/// platforms.
134///
135/// Usage: low 32 bits of the first 4 bytes of the GuidPrefix.
136#[must_use]
137pub fn peer_key_to_id(pk: &PeerKey) -> u32 {
138    let mut buf = [0u8; 4];
139    buf.copy_from_slice(&pk[..4]);
140    u32::from_le_bytes(buf)
141}
142
143fn poisoned<T>(_: PoisonError<T>) -> SecurityGateError {
144    SecurityGateError::Crypto(zerodds_security::error::SecurityError::new(
145        zerodds_security::error::SecurityErrorKind::Internal,
146        "security-runtime: mutex poisoned",
147    ))
148}
149
150impl SharedSecurityGate {
151    /// Constructor. The plugin is **owned** by the gate (Box).
152    #[must_use]
153    pub fn new(
154        domain_id: u32,
155        governance: Governance,
156        crypto: Box<dyn CryptographicPlugin>,
157    ) -> Self {
158        Self {
159            inner: Arc::new(Mutex::new(GateInner {
160                domain_id,
161                governance,
162                crypto,
163                local: None,
164                peers: BTreeMap::new(),
165            })),
166        }
167    }
168
169    fn with_inner<R>(
170        &self,
171        f: impl FnOnce(&mut GateInner) -> Result<R, SecurityGateError>,
172    ) -> Result<R, SecurityGateError> {
173        let mut g = self.inner.lock().map_err(poisoned)?;
174        f(&mut g)
175    }
176
177    /// Returns the domain id the gate runs for.
178    pub fn domain_id(&self) -> Result<u32, SecurityGateError> {
179        self.with_inner(|g| Ok(g.domain_id))
180    }
181
182    /// `ProtectionKind` for the message level — derived from the first
183    /// matching `<domain_rule>`.
184    pub fn message_protection(&self) -> Result<ProtectionKind, SecurityGateError> {
185        self.with_inner(|g| {
186            Ok(g.governance
187                .find_domain_rule(g.domain_id)
188                .map(|r| r.rtps_protection_kind)
189                .unwrap_or(ProtectionKind::None))
190        })
191    }
192
193    /// `ProtectionLevel` for user DATA — derived from the
194    /// `data_protection_kind` of the domain's first `<topic_rule>`
195    /// (FU2 S3). Used by the user outbound path as a fallback when
196    /// the matched reader has not announced an explicit `security_info` level via
197    /// SEDP — this way ZeroDDS↔ZeroDDS encrypts user data
198    /// according to its own governance, while SPDP/SEDP metatraffic
199    /// bootstraps plaintext via `rtps_protection_kind=NONE`.
200    ///
201    /// # Errors
202    /// Mutex poison.
203    pub fn data_protection(&self) -> Result<ProtectionLevel, SecurityGateError> {
204        self.with_inner(|g| {
205            let kind = g
206                .governance
207                .find_domain_rule(g.domain_id)
208                .and_then(|r| r.topic_rules.first())
209                .map(|t| t.data_protection_kind)
210                .unwrap_or(ProtectionKind::None);
211            Ok(ProtectionLevel::from_protection_kind(kind))
212        })
213    }
214
215    /// `ProtectionLevel` for submessage metadata — from the
216    /// `metadata_protection_kind` of the domain's first `<topic_rule>`.
217    /// Controls (together with [`Self::data_protection`]) the
218    /// `endpoint_security_attributes` that ZeroDDS announces via SEDP —
219    /// cross-vendor the mask must match cyclone/OpenDDS byte-exactly
220    /// (otherwise "security_attributes mismatch" → no endpoint match).
221    ///
222    /// # Errors
223    /// Mutex poison.
224    pub fn metadata_protection(&self) -> Result<ProtectionLevel, SecurityGateError> {
225        self.with_inner(|g| {
226            let kind = g
227                .governance
228                .find_domain_rule(g.domain_id)
229                .and_then(|r| r.topic_rules.first())
230                .map(|t| t.metadata_protection_kind)
231                .unwrap_or(ProtectionKind::None);
232            Ok(ProtectionLevel::from_protection_kind(kind))
233        })
234    }
235
236    /// `ProtectionLevel` for protected discovery — from the DOMAIN-wide
237    /// `discovery_protection_kind` (DDS-Security §8.4.2.4 `is_discovery_
238    /// protected`). `!= None` ⟹ secured endpoints are announced via the secure SEDP
239    /// (DCPSPublicationsSecure/DCPSSubscriptionsSecure) instead of plaintext SEDP;
240    /// the DATA/HEARTBEAT/GAP of the secure-SEDP writers are protected with the
241    /// participant data key via `encode_datawriter_submessage`.
242    ///
243    /// # Errors
244    /// Mutex poison.
245    pub fn discovery_protection(&self) -> Result<ProtectionLevel, SecurityGateError> {
246        self.with_inner(|g| {
247            let kind = g
248                .governance
249                .find_domain_rule(g.domain_id)
250                .map(|r| r.discovery_protection_kind)
251                .unwrap_or(ProtectionKind::None);
252            Ok(ProtectionLevel::from_protection_kind(kind))
253        })
254    }
255
256    /// `true` if the domain's first `<topic_rule>` sets
257    /// `enable_discovery_protection`. Controls the endpoint bit
258    /// `IS_DISCOVERY_PROTECTED` of the `EndpointSecurityAttributes`
259    /// (§9.4.2.4) — this is a TOPIC-level flag, NOT the domain-wide
260    /// [`Self::discovery_protection`] (= `discovery_protection_kind`, which
261    /// only protects the SEDP channel). cyclone derives the endpoint mask from
262    /// this boolean; ZeroDDS must announce byte-exactly the same,
263    /// otherwise "security_attributes mismatch" → no endpoint match.
264    ///
265    /// # Errors
266    /// Mutex poison.
267    pub fn topic_discovery_protected(&self) -> Result<bool, SecurityGateError> {
268        self.with_inner(|g| {
269            Ok(g.governance
270                .find_domain_rule(g.domain_id)
271                .and_then(|r| r.topic_rules.first())
272                .map(|t| t.enable_discovery_protection)
273                .unwrap_or(false))
274        })
275    }
276
277    /// `true` if the domain's first `<topic_rule>` sets
278    /// `enable_read_access_control` → endpoint bit `IS_READ_PROTECTED`
279    /// (0x01) of the `EndpointSecurityAttributes` (§9.4.2.4). cyclone sets it for
280    /// access-control topics; ZeroDDS must announce byte-exactly the same mask.
281    ///
282    /// # Errors
283    /// Mutex poison.
284    pub fn topic_read_protected(&self) -> Result<bool, SecurityGateError> {
285        self.with_inner(|g| {
286            Ok(g.governance
287                .find_domain_rule(g.domain_id)
288                .and_then(|r| r.topic_rules.first())
289                .map(|t| t.enable_read_access_control)
290                .unwrap_or(false))
291        })
292    }
293
294    /// `true` if the domain's first `<topic_rule>` sets
295    /// `enable_write_access_control` → endpoint bit `IS_WRITE_PROTECTED`
296    /// (0x02) of the `EndpointSecurityAttributes` (§9.4.2.4).
297    ///
298    /// # Errors
299    /// Mutex poison.
300    pub fn topic_write_protected(&self) -> Result<bool, SecurityGateError> {
301        self.with_inner(|g| {
302            Ok(g.governance
303                .find_domain_rule(g.domain_id)
304                .and_then(|r| r.topic_rules.first())
305                .map(|t| t.enable_write_access_control)
306                .unwrap_or(false))
307        })
308    }
309
310    /// `ProtectionLevel` for the whole RTPS message — DOMAIN-wide
311    /// `rtps_protection_kind` (§8.4.2.4 `is_rtps_protected`). Flows into the
312    /// `ParticipantSecurityAttributes` mask (§9.4.2.4) that ZeroDDS announces via
313    /// SPDP.
314    ///
315    /// # Errors
316    /// Mutex poison.
317    pub fn rtps_protection(&self) -> Result<ProtectionLevel, SecurityGateError> {
318        self.with_inner(|g| {
319            let kind = g
320                .governance
321                .find_domain_rule(g.domain_id)
322                .map(|r| r.rtps_protection_kind)
323                .unwrap_or(ProtectionKind::None);
324            Ok(ProtectionLevel::from_protection_kind(kind))
325        })
326    }
327
328    /// `ProtectionLevel` for liveliness (BuiltinParticipantMessageSecure) —
329    /// DOMAIN-wide `liveliness_protection_kind` (§8.4.2.4).
330    ///
331    /// # Errors
332    /// Mutex poison.
333    pub fn liveliness_protection(&self) -> Result<ProtectionLevel, SecurityGateError> {
334        self.with_inner(|g| {
335            let kind = g
336                .governance
337                .find_domain_rule(g.domain_id)
338                .map(|r| r.liveliness_protection_kind)
339                .unwrap_or(ProtectionKind::None);
340            Ok(ProtectionLevel::from_protection_kind(kind))
341        })
342    }
343
344    /// Registers the local participant with the crypto plugin (idempotent).
345    ///
346    /// # Errors
347    /// `CryptoSetup` if the plugin does not accept the identity handle.
348    pub fn ensure_local(&self) -> Result<CryptoHandle, SecurityGateError> {
349        self.with_inner(|g| {
350            if let Some(h) = g.local {
351                return Ok(h);
352            }
353            let h = g
354                .crypto
355                .register_local_participant(IdentityHandle(1), &[])
356                .map_err(SecurityGateError::CryptoSetup)?;
357            g.local = Some(h);
358            Ok(h)
359        })
360    }
361
362    /// Token of the local participant (to be sent to the remote via SEDP).
363    ///
364    /// # Errors
365    /// See [`SecurityGateError`].
366    pub fn local_token(&self) -> Result<Vec<u8>, SecurityGateError> {
367        let local = self.ensure_local()?;
368        self.with_inner(|g| {
369            g.crypto
370                .create_local_participant_crypto_tokens(local, CryptoHandle(0))
371                .map_err(SecurityGateError::Crypto)
372        })
373    }
374
375    /// Registers a remote peer and installs its token.
376    /// The returned `CryptoHandle` identifies the slot in
377    /// which the remote key is stored — needed again at `decode_inbound_message`.
378    ///
379    /// # Errors
380    /// See [`SecurityGateError`].
381    pub fn register_remote_with_token(
382        &self,
383        remote_identity: IdentityHandle,
384        shared_secret: SharedSecretHandle,
385        token: &[u8],
386    ) -> Result<CryptoHandle, SecurityGateError> {
387        let local = self.ensure_local()?;
388        self.with_inner(|g| {
389            let slot = g
390                .crypto
391                .register_matched_remote_participant(local, remote_identity, shared_secret)
392                .map_err(SecurityGateError::CryptoSetup)?;
393            g.crypto
394                .set_remote_participant_crypto_tokens(local, slot, token)
395                .map_err(SecurityGateError::Crypto)?;
396            Ok(slot)
397        })
398    }
399
400    /// Registers a remote peer **with peer-key mapping**. After
401    /// this call [`Self::transform_inbound_from`] can find the peer
402    /// by its [`PeerKey`] (GuidPrefix).
403    ///
404    /// Idempotent: a repeated call with the same key does not overwrite the
405    /// old slot — the caller must explicitly call
406    /// [`Self::forget_remote`] to be able to rotate.
407    ///
408    /// # Errors
409    /// See [`SecurityGateError`].
410    pub fn register_remote_by_guid(
411        &self,
412        peer_key: PeerKey,
413        remote_identity: IdentityHandle,
414        shared_secret: SharedSecretHandle,
415        token: &[u8],
416    ) -> Result<CryptoHandle, SecurityGateError> {
417        // Idempotency check: if already registered, return the existing
418        // handle.
419        {
420            let g = self.inner.lock().map_err(poisoned)?;
421            if let Some(h) = g.peers.get(&peer_key) {
422                return Ok(*h);
423            }
424        }
425        let slot = self.register_remote_with_token(remote_identity, shared_secret, token)?;
426        self.with_inner(|g| {
427            g.peers.insert(peer_key, slot);
428            Ok(())
429        })?;
430        Ok(slot)
431    }
432
433    /// FU2 S1.2: Registers a remote peer **only with the Kx key**
434    /// (from the handshake `SharedSecret`), WITHOUT a data token. The data
435    /// key is installed later via [`Self::set_remote_data_token_by_guid`] from
436    /// the received ParticipantCryptoToken.
437    ///
438    /// Maps `peer_key` → slot; idempotent (a repeated call with the
439    /// same key returns the existing slot).
440    ///
441    /// # Errors
442    /// `CryptoSetup` if the plugin does not accept the secret.
443    pub fn register_remote_by_guid_from_secret(
444        &self,
445        peer_key: PeerKey,
446        remote_identity: IdentityHandle,
447        shared_secret: SharedSecretHandle,
448    ) -> Result<CryptoHandle, SecurityGateError> {
449        {
450            let g = self.inner.lock().map_err(poisoned)?;
451            if let Some(h) = g.peers.get(&peer_key) {
452                return Ok(*h);
453            }
454        }
455        let local = self.ensure_local()?;
456        self.with_inner(|g| {
457            let slot = g
458                .crypto
459                .register_matched_remote_participant(local, remote_identity, shared_secret)
460                .map_err(SecurityGateError::CryptoSetup)?;
461            g.peers.insert(peer_key, slot);
462            Ok(slot)
463        })
464    }
465
466    /// FU2 S1.2: Installs the **data key** of a peer already registered via
467    /// [`Self::register_remote_by_guid_from_secret`]
468    /// from its received ParticipantCryptoToken (token exchange).
469    ///
470    /// # Errors
471    /// `PolicyViolation` if the peer is not registered; `Crypto`
472    /// on an invalid token.
473    pub fn set_remote_data_token_by_guid(
474        &self,
475        peer_key: &PeerKey,
476        token: &[u8],
477    ) -> Result<(), SecurityGateError> {
478        let local = self.ensure_local()?;
479        self.with_inner(|g| {
480            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
481                SecurityGateError::PolicyViolation(alloc::format!(
482                    "set_remote_data_token: peer {peer_key:?} not registered"
483                ))
484            })?;
485            g.crypto
486                .set_remote_participant_crypto_tokens(local, slot, token)
487                .map_err(SecurityGateError::Crypto)
488        })
489    }
490
491    /// FU2 S1.2: Encrypts a VolatileSecure payload (e.g. a
492    /// ParticipantCryptoToken) with the peer's **Kx key**
493    /// (`encode_kx_submessage`). The peer must be registered via
494    /// [`Self::register_remote_by_guid_from_secret`].
495    ///
496    /// # Errors
497    /// `PolicyViolation` (unknown peer) / `Crypto`.
498    pub fn transform_kx_outbound_for(
499        &self,
500        peer_key: &PeerKey,
501        plaintext: &[u8],
502    ) -> Result<Vec<u8>, SecurityGateError> {
503        self.with_inner(|g| {
504            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
505                SecurityGateError::PolicyViolation(alloc::format!(
506                    "transform_kx_outbound: peer {peer_key:?} not registered"
507                ))
508            })?;
509            g.crypto
510                .encode_kx_submessage(slot, plaintext, &[])
511                .map_err(SecurityGateError::Crypto)
512        })
513    }
514
515    /// FU2 S1.2: Decrypts a Kx-protected VolatileSecure
516    /// payload of a peer (`decode_kx_submessage`).
517    ///
518    /// # Errors
519    /// `PolicyViolation` (unknown peer) / `Crypto` (tag mismatch).
520    pub fn transform_kx_inbound_from(
521        &self,
522        peer_key: &PeerKey,
523        wire: &[u8],
524    ) -> Result<Vec<u8>, SecurityGateError> {
525        self.with_inner(|g| {
526            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
527                SecurityGateError::PolicyViolation(alloc::format!(
528                    "transform_kx_inbound: peer {peer_key:?} not registered"
529                ))
530            })?;
531            g.crypto
532                .decode_kx_submessage(slot, wire, &[])
533                .map_err(SecurityGateError::Crypto)
534        })
535    }
536
537    /// Cross-vendor VolatileSecure: protects a **DATA submessage** as a
538    /// cyclone-conform `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX` sequence with the
539    /// peer's Kx key (`encode_kx_datawriter_submessage`).
540    ///
541    /// # Errors
542    /// `PolicyViolation` (unknown peer) / `Crypto`.
543    pub fn encode_kx_datawriter_for(
544        &self,
545        peer_key: &PeerKey,
546        data_submessage: &[u8],
547    ) -> Result<Vec<u8>, SecurityGateError> {
548        self.with_inner(|g| {
549            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
550                SecurityGateError::PolicyViolation(alloc::format!(
551                    "encode_kx_datawriter: peer {peer_key:?} not registered"
552                ))
553            })?;
554            g.crypto
555                .encode_kx_datawriter_submessage(slot, data_submessage)
556                .map_err(SecurityGateError::Crypto)
557        })
558    }
559
560    /// Counterpart: decodes a peer's `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX` sequence
561    /// back to the original DATA submessage (`decode_kx_datawriter_submessage`).
562    ///
563    /// # Errors
564    /// `PolicyViolation` (unknown peer) / `Crypto` (tag mismatch).
565    pub fn decode_kx_datawriter_from(
566        &self,
567        peer_key: &PeerKey,
568        wire: &[u8],
569    ) -> Result<Vec<u8>, SecurityGateError> {
570        self.with_inner(|g| {
571            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
572                SecurityGateError::PolicyViolation(alloc::format!(
573                    "decode_kx_datawriter: peer {peer_key:?} not registered"
574                ))
575            })?;
576            g.crypto
577                .decode_kx_datawriter_submessage(slot, wire)
578                .map_err(SecurityGateError::Crypto)
579        })
580    }
581
582    /// Cross-vendor user DATA (send): protects a DATA submessage with the
583    /// **local data key** as a cyclone-conform SEC_PREFIX/BODY/POSTFIX sequence
584    /// (`encode_data_datawriter_submessage`). cyclone decodes it with the
585    /// (= local) key sent by ZeroDDS via `datawriter_crypto_tokens`.
586    ///
587    /// # Errors
588    /// `CryptoSetup` (no local slot) / `Crypto`.
589    pub fn encode_data_datawriter_local(
590        &self,
591        data_submessage: &[u8],
592    ) -> Result<Vec<u8>, SecurityGateError> {
593        let local = self.ensure_local()?;
594        self.with_inner(|g| {
595            g.crypto
596                .encode_data_datawriter_submessage(local, data_submessage)
597                .map_err(SecurityGateError::Crypto)
598        })
599    }
600
601    // -------- per-endpoint crypto (DDS-Security §8.5 CryptoKeyFactory + §9.5.3.3) --------
602    //
603    // Instead of a flat participant key for ALL endpoints, each
604    // local DataWriter/DataReader (incl. the secure builtin discovery endpoints)
605    // gets its OWN key material. On endpoint match the per-endpoint token is
606    // exchanged (deterministically, over the reliable VolatileSecure), so that
607    // the keys stand BEFORE the data — no fail-first decode retry. cyclone/
608    // FastDDS work identically.
609
610    /// Registers a local endpoint crypto slot with its own key material.
611    /// `is_writer` = DataWriter (else DataReader). Returns the endpoint handle.
612    ///
613    /// # Errors
614    /// `CryptoSetup` (no local participant) / plugin error.
615    pub fn register_local_endpoint(
616        &self,
617        is_writer: bool,
618    ) -> Result<CryptoHandle, SecurityGateError> {
619        let local = self.ensure_local()?;
620        self.with_inner(|g| {
621            g.crypto
622                .register_local_endpoint(local, is_writer, &[])
623                .map_err(SecurityGateError::CryptoSetup)
624        })
625    }
626
627    /// Serializes the per-endpoint `datawriter`/`datareader_crypto_token`
628    /// (the key material of the local endpoint slot), to be sent to the matched
629    /// remote endpoint via VolatileSecure.
630    ///
631    /// # Errors
632    /// `Crypto` (unknown endpoint handle).
633    pub fn create_endpoint_token(
634        &self,
635        endpoint: CryptoHandle,
636    ) -> Result<Vec<u8>, SecurityGateError> {
637        self.with_inner(|g| {
638            g.crypto
639                .create_local_participant_crypto_tokens(endpoint, CryptoHandle(0))
640                .map_err(SecurityGateError::Crypto)
641        })
642    }
643
644    /// Installs a received per-endpoint token. The key material is
645    /// indexed via its `transformation_key_id` (`remote_by_key_id`), so that
646    /// [`Self::decode_data_by_key_id`] maps the submessages of this remote endpoint
647    /// — independent of the participant key.
648    ///
649    /// # Errors
650    /// `CryptoSetup` (no local slot) / `Crypto`.
651    /// Optional second (payload) token of a DataWriter endpoint for the
652    /// cyclone dual-key model (metadata != data, e.g. meta-sign-data). `None`
653    /// = single key (all other profiles).
654    #[must_use]
655    pub fn endpoint_payload_token(&self, endpoint: CryptoHandle) -> Option<alloc::vec::Vec<u8>> {
656        self.with_inner(|g| Ok(g.crypto.endpoint_payload_token(endpoint)))
657            .ok()
658            .flatten()
659    }
660
661    /// Installs a remote endpoint crypto token (volatile key exchange):
662    /// passes the received token via `set_remote_participant_crypto_tokens` to
663    /// the crypto plugin (participant handle `CryptoHandle(0)` = per-endpoint).
664    pub fn install_remote_endpoint_token(&self, token: &[u8]) -> Result<(), SecurityGateError> {
665        let local = self.ensure_local()?;
666        self.with_inner(|g| {
667            g.crypto
668                .set_remote_participant_crypto_tokens(local, CryptoHandle(0), token)
669                .map_err(SecurityGateError::Crypto)
670        })
671    }
672
673    /// Encodes a DATA submessage with the **per-endpoint** key (not the
674    /// participant). Wire-identical to cyclone `encode_datawriter_submessage`.
675    ///
676    /// # Errors
677    /// `Crypto` (unknown endpoint handle / encode error).
678    pub fn encode_data_datawriter_by_handle(
679        &self,
680        endpoint: CryptoHandle,
681        data_submessage: &[u8],
682    ) -> Result<Vec<u8>, SecurityGateError> {
683        self.with_inner(|g| {
684            g.crypto
685                .encode_data_datawriter_submessage(endpoint, data_submessage)
686                .map_err(SecurityGateError::Crypto)
687        })
688    }
689
690    /// Cross-vendor user DATA (recv): decodes a SEC_* sequence with the
691    /// **sender's data key** (peer slot, filled via token exchange).
692    ///
693    /// # Errors
694    /// `PolicyViolation` (unknown peer) / `Crypto` (tag mismatch).
695    pub fn decode_data_datawriter_from(
696        &self,
697        peer_key: &PeerKey,
698        wire: &[u8],
699    ) -> Result<Vec<u8>, SecurityGateError> {
700        self.with_inner(|g| {
701            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
702                SecurityGateError::PolicyViolation(alloc::format!(
703                    "decode_data_datawriter: peer {peer_key:?} not registered"
704                ))
705            })?;
706            g.crypto
707                .decode_data_datawriter_submessage(slot, wire)
708                .map_err(SecurityGateError::Crypto)
709        })
710    }
711
712    /// Cross-vendor (recv) by **key_id**: decodes a SEC_* submessage without
713    /// knowing the endpoint handle — the plugin looks up the remote key material
714    /// by the `transformation_key_id` in the CryptoHeader (§9.5.2.1.1). Needed
715    /// for protected discovery, where a peer has its own key per secure builtin
716    /// endpoint.
717    ///
718    /// # Errors
719    /// `Crypto` (no key for the key_id / tag mismatch).
720    pub fn decode_data_by_key_id(&self, wire: &[u8]) -> Result<Vec<u8>, SecurityGateError> {
721        self.with_inner(|g| {
722            g.crypto
723                .decode_data_by_key_id(wire)
724                .map_err(SecurityGateError::Crypto)
725        })
726    }
727
728    /// §9.5.3.3.1 data_protection (send): encrypts the SerializedPayload
729    /// of ONE endpoint (per-endpoint writer key) as the inner layer. Cross-vendor
730    /// needed: with `data_protection_kind=ENCRYPT` cyclone expects the
731    /// encrypted payload (`decode_serialized_payload`).
732    ///
733    /// # Errors
734    /// `Crypto` (no key / seal error).
735    pub fn encode_serialized_payload(
736        &self,
737        endpoint: CryptoHandle,
738        payload: &[u8],
739    ) -> Result<Vec<u8>, SecurityGateError> {
740        self.with_inner(|g| {
741            g.crypto
742                .encode_serialized_payload(endpoint, payload)
743                .map_err(SecurityGateError::Crypto)
744        })
745    }
746
747    /// §9.5.3.3.1 data_protection (recv): key via `transformation_key_id`.
748    ///
749    /// # Errors
750    /// `Crypto` (no key for key_id / tag mismatch).
751    pub fn decode_serialized_payload(&self, encoded: &[u8]) -> Result<Vec<u8>, SecurityGateError> {
752        self.with_inner(|g| {
753            g.crypto
754                .decode_serialized_payload(encoded)
755                .map_err(SecurityGateError::Crypto)
756        })
757    }
758
759    /// Prefixes of all peers with which a data key is installed (participant
760    /// slot table). STABLE — unlike `completed_peer_prefixes` (derived from the
761    /// handshake entries GC'd after completion), this entry persists. Source for
762    /// re-sending per-endpoint crypto tokens to
763    /// late-matching user endpoints.
764    pub fn authenticated_peer_prefixes(&self) -> Vec<PeerKey> {
765        self.with_inner(|g| Ok(g.peers.keys().copied().collect()))
766            .unwrap_or_default()
767    }
768
769    /// §9.5.3.3.1 data_protection (recv) fallback: key via the **sender prefix**
770    /// (GuidPrefix slot table, token exchange) instead of key_id. zero↔zero indexes
771    /// the remote key material via the peer slot, not necessarily via a
772    /// unique `transformation_key_id` — analogous to `decode_data_datawriter_from`.
773    ///
774    /// # Errors
775    /// `PolicyViolation` (unknown peer) / `Crypto` (tag mismatch).
776    pub fn decode_serialized_payload_from(
777        &self,
778        peer_key: &PeerKey,
779        encoded: &[u8],
780    ) -> Result<Vec<u8>, SecurityGateError> {
781        self.with_inner(|g| {
782            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
783                SecurityGateError::PolicyViolation(alloc::format!(
784                    "decode_serialized_payload: peer {peer_key:?} not registered"
785                ))
786            })?;
787            g.crypto
788                .decode_serialized_payload_with(slot, encoded)
789                .map_err(SecurityGateError::Crypto)
790        })
791    }
792
793    /// §9.5.3.3.1 data_protection (recv) fallback with the **Kx/participant key**
794    /// of the peer slot (transformation_key_id=0). cyclone encrypts the
795    /// data_protection payload with the SharedSecret-derived participant key
796    /// instead of the per-endpoint DataWriter key — the same handle indexes the
797    /// Kx key material in `kx_slots` (§10.5.2.1.2 Tab. 73).
798    ///
799    /// # Errors
800    /// `PolicyViolation` (unknown peer) / `Crypto` (tag mismatch).
801    pub fn decode_serialized_payload_kx(
802        &self,
803        peer_key: &PeerKey,
804        encoded: &[u8],
805    ) -> Result<Vec<u8>, SecurityGateError> {
806        self.with_inner(|g| {
807            let slot = g.peers.get(peer_key).copied().ok_or_else(|| {
808                SecurityGateError::PolicyViolation(alloc::format!(
809                    "decode_serialized_payload_kx: peer {peer_key:?} not registered"
810                ))
811            })?;
812            g.crypto
813                .decode_serialized_payload_kx(slot, encoded)
814                .map_err(SecurityGateError::Crypto)
815        })
816    }
817
818    /// Removes the peer-key → slot mapping. The slot itself stays
819    /// in the plugin (key cleanup is the plugin's job on re-register).
820    pub fn forget_remote(&self, peer_key: &PeerKey) -> Result<(), SecurityGateError> {
821        self.with_inner(|g| {
822            g.peers.remove(peer_key);
823            Ok(())
824        })
825    }
826
827    /// Returns the slot for a peer key, if registered.
828    pub fn slot_for(&self, peer_key: &PeerKey) -> Result<Option<CryptoHandle>, SecurityGateError> {
829        self.with_inner(|g| Ok(g.peers.get(peer_key).copied()))
830    }
831
832    /// Inbound transform with **peer-key lookup**. The RTPS header
833    /// contains the sender's GuidPrefix on bytes 8..20 — the
834    /// caller must pass this here as `peer_key`.
835    ///
836    /// If the peer is not registered **and** the message
837    /// looks encrypted: `PolicyViolation` (unknown sender
838    /// sends SRTPS).
839    ///
840    /// # Errors
841    /// See [`SecurityGateError`].
842    pub fn transform_inbound_from(
843        &self,
844        peer_key: &PeerKey,
845        wire: &[u8],
846    ) -> Result<Vec<u8>, SecurityGateError> {
847        let looks_secured = wire.len() > RTPS_HEADER_LEN && wire[RTPS_HEADER_LEN] == SRTPS_PREFIX;
848        let kind = self.message_protection()?;
849        if !looks_secured {
850            // Passthrough or policy violation — same logic as in the
851            // slot-based path.
852            return if matches!(kind, ProtectionKind::None) {
853                Ok(wire.to_vec())
854            } else {
855                Err(SecurityGateError::PolicyViolation(alloc::format!(
856                    "domain requires {kind:?}, got a plain rtps message"
857                )))
858            };
859        }
860        let slot = self.slot_for(peer_key)?.ok_or_else(|| {
861            SecurityGateError::PolicyViolation(alloc::format!(
862                "unknown peer {peer_key:?} sends SRTPS_PREFIX"
863            ))
864        })?;
865        self.transform_inbound(slot, wire)
866    }
867
868    /// Outbound message: wrap if governance requires message protection.
869    ///
870    /// # Errors
871    /// See [`SecurityGateError`].
872    pub fn transform_outbound(&self, message: &[u8]) -> Result<Vec<u8>, SecurityGateError> {
873        match self.message_protection()? {
874            ProtectionKind::None => Ok(message.to_vec()),
875            _ => {
876                let local = self.ensure_local()?;
877                // Cyclone-conform message-level SRTPS: CryptoHeader with
878                // transformation_key_id in the SRTPS_PREFIX (cross-vendor decodable).
879                self.with_inner(|g| {
880                    g.crypto
881                        .encode_rtps_message_cyclone(local, message)
882                        .map_err(SecurityGateError::Crypto)
883                })
884            }
885        }
886    }
887
888    /// Group outbound with receiver-specific MACs.
889    ///
890    /// Uses [`encode_secured_submessage_multi`] when all receivers
891    /// are already registered in the gate via peer keys. Returns a
892    /// **single** wire datagram with a multi-MAC SEC_POSTFIX; the
893    /// caller can unicast the same datagram to all matched readers.
894    ///
895    /// The resulting wire is NOT an RTPS message-level wrapper —
896    /// it is a submessage sequence (SEC_PREFIX/BODY/POSTFIX). The
897    /// caller must put the RTPS header in front itself or use the
898    /// `transform_outbound_multi_wrapped` variant (follows if
899    /// needed — for stage 7 the submessage sequence suffices).
900    ///
901    /// # Errors
902    /// * `Crypto` passed through.
903    /// * `PolicyViolation` if a peer key is not registered.
904    pub fn transform_outbound_group(
905        &self,
906        peer_keys: &[PeerKey],
907        plaintext: &[u8],
908    ) -> Result<Vec<u8>, SecurityGateError> {
909        let local = self.ensure_local()?;
910        // Resolve all PeerKeys to (CryptoHandle, key_id) pairs. We
911        // derive the key_id deterministically from the PeerKey prefix
912        // (low 32 bits of the GuidPrefix) — both sides (sender +
913        // receiver) can compute it without an additional handshake.
914        // The caller must have created a slot per PeerKey beforehand via
915        // register_remote_by_guid.
916        let bindings: Vec<(CryptoHandle, u32)> = self.with_inner(|g| {
917            let mut out = Vec::with_capacity(peer_keys.len());
918            for pk in peer_keys {
919                let h = g.peers.get(pk).copied().ok_or_else(|| {
920                    SecurityGateError::PolicyViolation(alloc::format!(
921                        "transform_outbound_group: peer {pk:?} not registered"
922                    ))
923                })?;
924                out.push((h, peer_key_to_id(pk)));
925            }
926            Ok(out)
927        })?;
928        self.with_inner(|g| {
929            encode_secured_submessage_multi(&*g.crypto, local, &bindings, plaintext)
930                .map_err(SecurityGateError::from)
931        })
932    }
933
934    /// Group inbound: decodes a multi-MAC submessage sequence and
935    /// validates **our own** MAC.
936    ///
937    /// `sender_peer_key` is the sender's GuidPrefix (as registered in
938    /// [`Self::register_remote_by_guid`]). The receiver
939    /// MAC key comes from `ensure_local()` — at encoding the sender set
940    /// exactly this slot handle as a `remote_list` entry
941    /// (via `register_remote_by_guid(our_prefix, our_local_token)`).
942    ///
943    /// # Errors
944    /// * `PolicyViolation` if `sender_peer_key` is not registered.
945    /// * `Crypto` / `Wrapper` on a tag/MAC mismatch.
946    pub fn transform_inbound_group(
947        &self,
948        sender_peer_key: &PeerKey,
949        own_peer_key: &PeerKey,
950        wire: &[u8],
951    ) -> Result<Vec<u8>, SecurityGateError> {
952        let sender_slot = self.slot_for(sender_peer_key)?.ok_or_else(|| {
953            SecurityGateError::PolicyViolation(alloc::format!(
954                "transform_inbound_group: unknown sender {sender_peer_key:?}"
955            ))
956        })?;
957        // The MAC-key slot source is our own local slot (when the
958        // sender registered our PeerKey it stored exactly
959        // our local_token → same master key).
960        let own_local = self.ensure_local()?;
961        let own_id = peer_key_to_id(own_peer_key);
962        self.with_inner(|g| {
963            decode_secured_submessage_multi(
964                &*g.crypto,
965                sender_slot,
966                sender_slot,
967                own_id,
968                own_local,
969                wire,
970            )
971            .map_err(SecurityGateError::from)
972        })
973    }
974
975    /// Peer-specific outbound transform.
976    ///
977    /// Unlike [`Self::transform_outbound`], this
978    /// method ignores the domain rule and **instead** applies the
979    /// caller-given [`ProtectionLevel`]. This is the API that
980    /// the heterogeneous-security writer tick (per-reader serializer)
981    /// calls per matched reader.
982    ///
983    /// * `ProtectionLevel::None`    → plaintext passthrough
984    /// * `ProtectionLevel::Sign`    → RTPS message wrap (HMAC/GCM tag)
985    /// * `ProtectionLevel::Encrypt` → RTPS message wrap (AEAD ciphertext)
986    ///
987    /// The sign/encrypt distinction today uses the same encoder
988    /// as [`Self::transform_outbound`] — the current
989    /// `AesGcmCryptoPlugin` does not differentiate. Receiver-specific MACs
990    /// and further extensions retrofit the distinction. The
991    /// `peer_key` is carried along (for future per-peer crypto
992    /// handles) but not yet passed to the plugin.
993    ///
994    /// # Errors
995    /// See [`SecurityGateError`]. Never an error in the `None` path.
996    pub fn transform_outbound_for(
997        &self,
998        _peer_key: &PeerKey,
999        message: &[u8],
1000        level: ProtectionLevel,
1001    ) -> Result<Vec<u8>, SecurityGateError> {
1002        match level {
1003            ProtectionLevel::None => Ok(message.to_vec()),
1004            ProtectionLevel::Sign | ProtectionLevel::Encrypt => {
1005                let local = self.ensure_local()?;
1006                // Cyclone-conform message-level SRTPS (CryptoHeader with
1007                // transformation_key_id in the SRTPS_PREFIX) — identical to the
1008                // generic transform_outbound. The peer resolves the key by
1009                // key_id, so NO peer-specific keymat is needed.
1010                // (Previously: encode_secured_rtps_message = 16-byte null prefix,
1011                // which decode_rtps_message_cyclone rejects as !=20 -> the asymmetry
1012                // broke cross-instance/cross-vendor user DATA + 3 unit tests.)
1013                self.with_inner(|g| {
1014                    g.crypto
1015                        .encode_rtps_message_cyclone(local, message)
1016                        .map_err(SecurityGateError::Crypto)
1017                })
1018            }
1019        }
1020    }
1021
1022    /// Returns the `allow_unauthenticated_participants` flag from the
1023    /// domain rule. Default `false` if no rule exists for the domain
1024    /// — conservative-safe stance.
1025    pub fn allow_unauthenticated(&self) -> Result<bool, SecurityGateError> {
1026        self.with_inner(|g| {
1027            Ok(g.governance
1028                .find_domain_rule(g.domain_id)
1029                .map(|r| r.allow_unauthenticated_participants)
1030                .unwrap_or(false))
1031        })
1032    }
1033
1034    /// Classifies an incoming RTPS datagram against the domain rule,
1035    /// peer registration, wire format and network interface
1036    ///.
1037    ///
1038    /// Decision matrix:
1039    ///
1040    /// 1. `bytes.len() < 20` → `Malformed`.
1041    /// 2. Extract `peer_key` from `bytes[8..20]`.
1042    /// 3. If the packet is SRTPS-wrapped → standard unwrap path
1043    ///    (`transform_inbound_from`). On a crypto error `CryptoError`,
1044    ///    on an unknown peer `PolicyViolation`.
1045    /// 4. If the packet is plain AND the domain requires ProtectionKind::None
1046    ///    → `Accept`.
1047    /// 5. If the packet is plain AND the domain requires protection:
1048    ///    * the interface is `Loopback` or `LocalHost` → `Accept`
1049    ///      (bytes do not leave the host kernel — spec-conform
1050    ///      plaintext on host-local transport)
1051    ///    * `allow_unauthenticated_participants = true` → `Accept`
1052    ///    * otherwise → `LegacyBlocked`
1053    ///
1054    /// The `iface` context is currently consulted in the rules only for the
1055    /// loopback fast path; the finer per-interface peer/topic
1056    /// classification is handled by the `PolicyEngine`
1057    /// from stage 8 on (governance-XML `<interface_bindings>`).
1058    #[must_use]
1059    pub fn classify_inbound(&self, bytes: &[u8], iface: &NetInterface) -> InboundVerdict {
1060        if bytes.len() < RTPS_HEADER_LEN + 8 {
1061            return InboundVerdict::Malformed;
1062        }
1063        let mut peer_key = [0u8; 12];
1064        peer_key.copy_from_slice(&bytes[8..20]);
1065
1066        let looks_secured = bytes.len() > RTPS_HEADER_LEN && bytes[RTPS_HEADER_LEN] == SRTPS_PREFIX;
1067        let kind = match self.message_protection() {
1068            Ok(k) => k,
1069            Err(e) => {
1070                return InboundVerdict::CryptoError(alloc::format!("gate lookup failed: {e:?}"));
1071            }
1072        };
1073
1074        if looks_secured {
1075            return match self.transform_inbound_from(&peer_key, bytes) {
1076                Ok(clear) => InboundVerdict::Accept(clear),
1077                Err(SecurityGateError::PolicyViolation(msg)) => {
1078                    InboundVerdict::PolicyViolation(msg)
1079                }
1080                Err(e) => InboundVerdict::CryptoError(alloc::format!("{e:?}")),
1081            };
1082        }
1083
1084        // A plain packet came in.
1085        if matches!(kind, ProtectionKind::None) {
1086            return InboundVerdict::Accept(bytes.to_vec());
1087        }
1088        // Loopback / LocalHost: bytes do not leave the host, so
1089        // plain is functionally OK — matches arch doc §2.1 "intra-
1090        // host loopback: plain (does not leave the network)".
1091        if matches!(iface, NetInterface::Loopback | NetInterface::LocalHost) {
1092            return InboundVerdict::Accept(bytes.to_vec());
1093        }
1094        // The domain requires protection, the peer sent plain on a remote
1095        // interface.
1096        match self.allow_unauthenticated() {
1097            Ok(true) => InboundVerdict::Accept(bytes.to_vec()),
1098            Ok(false) => InboundVerdict::LegacyBlocked,
1099            Err(e) => InboundVerdict::CryptoError(alloc::format!("gate lookup failed: {e:?}")),
1100        }
1101    }
1102
1103    /// Inbound message: unwrap if SRTPS_PREFIX is detected, otherwise
1104    /// passthrough or PolicyViolation.
1105    ///
1106    /// `remote_slot` points to the slot in which the sender key
1107    /// is stored (from [`Self::register_remote_with_token`]).
1108    ///
1109    /// # Errors
1110    /// See [`SecurityGateError`].
1111    pub fn transform_inbound(
1112        &self,
1113        remote_slot: CryptoHandle,
1114        wire: &[u8],
1115    ) -> Result<Vec<u8>, SecurityGateError> {
1116        let looks_secured = wire.len() > RTPS_HEADER_LEN && wire[RTPS_HEADER_LEN] == SRTPS_PREFIX;
1117        let kind = self.message_protection()?;
1118        match (kind, looks_secured) {
1119            (ProtectionKind::None, false) => Ok(wire.to_vec()),
1120            (_, true) => self.with_inner(|g| {
1121                // key_id-based SRTPS decode (cyclone-conform); remote_slot
1122                // stays for the signature contract, the key comes via key_id.
1123                let _ = remote_slot;
1124                g.crypto
1125                    .decode_rtps_message_cyclone(wire)
1126                    .map_err(SecurityGateError::Crypto)
1127            }),
1128            (_, false) => Err(SecurityGateError::PolicyViolation(alloc::format!(
1129                "domain requires {kind:?}, got a plain rtps message"
1130            ))),
1131        }
1132    }
1133}
1134
1135#[cfg(test)]
1136#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1137mod tests {
1138    use super::*;
1139    use std::thread;
1140    use zerodds_security_crypto::AesGcmCryptoPlugin;
1141    use zerodds_security_permissions::parse_governance_xml;
1142
1143    const GOV_RTPS: &str = r#"
1144<domain_access_rules>
1145  <domain_rule>
1146    <domains><id>0</id></domains>
1147    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
1148    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
1149  </domain_rule>
1150</domain_access_rules>
1151"#;
1152
1153    fn fake_msg(body: &[u8]) -> Vec<u8> {
1154        let mut m = Vec::with_capacity(20 + body.len());
1155        m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
1156        m.extend_from_slice(&[0u8; 12]);
1157        m.extend_from_slice(body);
1158        m
1159    }
1160
1161    #[test]
1162    fn outbound_none_is_passthrough() {
1163        let gov = parse_governance_xml(
1164            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>"#,
1165        )
1166        .unwrap();
1167        let gate = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1168        let msg = fake_msg(b"x");
1169        assert_eq!(gate.transform_outbound(&msg).unwrap(), msg);
1170    }
1171
1172    #[test]
1173    fn per_endpoint_datawriter_token_roundtrips_by_key_id() {
1174        // §9.5.3.3: each DataWriter has its OWN key material; the matched
1175        // reader installs it via datawriter_crypto_tokens + decodes the
1176        // submessage via the transformation_key_id — NOT via the
1177        // participant key. Deterministic per-endpoint crypto path (replaces
1178        // the flat participant dump that caused the keys-before-data race).
1179        let alice = SharedSecurityGate::new(
1180            0,
1181            parse_governance_xml(GOV_RTPS).unwrap(),
1182            Box::new(AesGcmCryptoPlugin::new()),
1183        );
1184        let bob = SharedSecurityGate::new(
1185            0,
1186            parse_governance_xml(GOV_RTPS).unwrap(),
1187            Box::new(AesGcmCryptoPlugin::new()),
1188        );
1189
1190        // Alice: local writer endpoint with its own key (NOT participant).
1191        let aw = alice.register_local_endpoint(true).unwrap();
1192        // Alice → Bob: the per-endpoint datawriter_crypto_token.
1193        let token = alice.create_endpoint_token(aw).unwrap();
1194        bob.install_remote_endpoint_token(&token).unwrap();
1195
1196        // Alice encodes a DATA submessage with the ENDPOINT key.
1197        let plain = fake_msg(b"[DATA:per-endpoint]");
1198        let wire = alice.encode_data_datawriter_by_handle(aw, &plain).unwrap();
1199        // Bob decodes it exclusively via the key_id in the CryptoHeader.
1200        let back = bob.decode_data_by_key_id(&wire).unwrap();
1201        assert_eq!(back, plain, "per-endpoint key round-trips via key_id");
1202    }
1203
1204    #[test]
1205    fn topic_discovery_protected_reads_topic_rule_flag() {
1206        // §9.4.2.4: the endpoint bit IS_DISCOVERY_PROTECTED comes from the
1207        // TOPIC rule `enable_discovery_protection` (boolean) — NOT from the
1208        // domain-wide discovery_protection_kind. cyclone sets it for
1209        // enable_discovery_protection=true; ZeroDDS must announce the same mask
1210        // (otherwise "security_attributes mismatch").
1211        const GOV_DISC: &str = r#"
1212<domain_access_rules>
1213  <domain_rule>
1214    <domains><id>0</id></domains>
1215    <discovery_protection_kind>ENCRYPT</discovery_protection_kind>
1216    <topic_access_rules><topic_rule>
1217      <topic_expression>*</topic_expression>
1218      <enable_discovery_protection>true</enable_discovery_protection>
1219    </topic_rule></topic_access_rules>
1220  </domain_rule>
1221</domain_access_rules>
1222"#;
1223        let on = SharedSecurityGate::new(
1224            0,
1225            parse_governance_xml(GOV_DISC).unwrap(),
1226            Box::new(AesGcmCryptoPlugin::new()),
1227        );
1228        assert!(
1229            on.topic_discovery_protected().unwrap(),
1230            "enable_discovery_protection=true ⟹ endpoint IS_DISCOVERY_PROTECTED"
1231        );
1232        // GOV_RTPS has NO enable_discovery_protection → false.
1233        let off = SharedSecurityGate::new(
1234            0,
1235            parse_governance_xml(GOV_RTPS).unwrap(),
1236            Box::new(AesGcmCryptoPlugin::new()),
1237        );
1238        assert!(!off.topic_discovery_protected().unwrap());
1239    }
1240
1241    #[test]
1242    fn e2e_alice_bob_with_shared_gate() {
1243        let alice = SharedSecurityGate::new(
1244            0,
1245            parse_governance_xml(GOV_RTPS).unwrap(),
1246            Box::new(AesGcmCryptoPlugin::new()),
1247        );
1248        let bob = SharedSecurityGate::new(
1249            0,
1250            parse_governance_xml(GOV_RTPS).unwrap(),
1251            Box::new(AesGcmCryptoPlugin::new()),
1252        );
1253        let alice_token = alice.local_token().unwrap();
1254        let bob_view_of_alice = bob
1255            .register_remote_with_token(IdentityHandle(1), SharedSecretHandle(1), &alice_token)
1256            .unwrap();
1257
1258        let plain = fake_msg(b"[DATA:shared]");
1259        let wire = alice.transform_outbound(&plain).unwrap();
1260        let back = bob.transform_inbound(bob_view_of_alice, &wire).unwrap();
1261        assert_eq!(back, plain);
1262    }
1263
1264    #[test]
1265    fn clone_shares_same_plugin_instance() {
1266        // Clone creates a second gate handle on THE SAME plugin.
1267        // `ensure_local` by clone1 creates the local slot; clone2
1268        // sees the same session ID.
1269        let gate1 = SharedSecurityGate::new(
1270            0,
1271            parse_governance_xml(GOV_RTPS).unwrap(),
1272            Box::new(AesGcmCryptoPlugin::new()),
1273        );
1274        let gate2 = gate1.clone();
1275        let t1 = gate1.local_token().unwrap();
1276        let t2 = gate2.local_token().unwrap();
1277        assert_eq!(t1, t2, "both clones see the same local slot");
1278    }
1279
1280    #[test]
1281    fn concurrent_transform_is_thread_safe() {
1282        let alice = SharedSecurityGate::new(
1283            0,
1284            parse_governance_xml(GOV_RTPS).unwrap(),
1285            Box::new(AesGcmCryptoPlugin::new()),
1286        );
1287        let mut handles = Vec::new();
1288        for i in 0..8 {
1289            let g = alice.clone();
1290            handles.push(thread::spawn(move || {
1291                let m = fake_msg(alloc::format!("[DATA:{i}]").as_bytes());
1292                // Must serialize WITHOUT panic — the nonce counter stays
1293                // thread-safe (AtomicU64 in the key material).
1294                let _ = g.transform_outbound(&m).unwrap();
1295            }));
1296        }
1297        for h in handles {
1298            h.join().unwrap();
1299        }
1300    }
1301
1302    #[test]
1303    fn plain_inbound_on_protected_domain_is_policy_violation() {
1304        let gate = SharedSecurityGate::new(
1305            0,
1306            parse_governance_xml(GOV_RTPS).unwrap(),
1307            Box::new(AesGcmCryptoPlugin::new()),
1308        );
1309        let plain = fake_msg(b"nope");
1310        let err = gate
1311            .transform_inbound(CryptoHandle(99), &plain)
1312            .unwrap_err();
1313        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
1314    }
1315
1316    #[test]
1317    fn domain_id_reflects_constructor() {
1318        let gate = SharedSecurityGate::new(
1319            7,
1320            parse_governance_xml(GOV_RTPS).unwrap(),
1321            Box::new(AesGcmCryptoPlugin::new()),
1322        );
1323        assert_eq!(gate.domain_id().unwrap(), 7);
1324    }
1325
1326    // -------------------------------------------------------------
1327    // RC1.4 preparation — peer-key mapping
1328    // -------------------------------------------------------------
1329
1330    fn build_pair() -> (SharedSecurityGate, SharedSecurityGate) {
1331        let alice = SharedSecurityGate::new(
1332            0,
1333            parse_governance_xml(GOV_RTPS).unwrap(),
1334            Box::new(AesGcmCryptoPlugin::new()),
1335        );
1336        let bob = SharedSecurityGate::new(
1337            0,
1338            parse_governance_xml(GOV_RTPS).unwrap(),
1339            Box::new(AesGcmCryptoPlugin::new()),
1340        );
1341        (alice, bob)
1342    }
1343
1344    #[test]
1345    fn register_remote_by_guid_is_idempotent() {
1346        let (alice, bob) = build_pair();
1347        let alice_prefix: PeerKey = [0xAA; 12];
1348        let atoken = alice.local_token().unwrap();
1349        let slot1 = bob
1350            .register_remote_by_guid(
1351                alice_prefix,
1352                IdentityHandle(1),
1353                SharedSecretHandle(1),
1354                &atoken,
1355            )
1356            .unwrap();
1357        let slot2 = bob
1358            .register_remote_by_guid(
1359                alice_prefix,
1360                IdentityHandle(1),
1361                SharedSecretHandle(1),
1362                &atoken,
1363            )
1364            .unwrap();
1365        assert_eq!(slot1, slot2, "idempotent: same guid prefix → same slot");
1366    }
1367
1368    #[test]
1369    fn transform_inbound_from_looks_up_slot_by_guid() {
1370        let (alice, bob) = build_pair();
1371        let alice_prefix: PeerKey = [0xAA; 12];
1372        let atoken = alice.local_token().unwrap();
1373        bob.register_remote_by_guid(
1374            alice_prefix,
1375            IdentityHandle(1),
1376            SharedSecretHandle(1),
1377            &atoken,
1378        )
1379        .unwrap();
1380
1381        let msg = fake_msg(b"[DATA:guid-lookup]");
1382        let wire = alice.transform_outbound(&msg).unwrap();
1383        let back = bob.transform_inbound_from(&alice_prefix, &wire).unwrap();
1384        assert_eq!(back, msg);
1385    }
1386
1387    #[test]
1388    fn transform_inbound_from_unknown_peer_is_policy_violation() {
1389        let (alice, bob) = build_pair();
1390        // Alice does NOT register with Bob.
1391        let msg = fake_msg(b"x");
1392        let wire = alice.transform_outbound(&msg).unwrap();
1393        let err = bob.transform_inbound_from(&[0xCC; 12], &wire).unwrap_err();
1394        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
1395    }
1396
1397    #[test]
1398    fn multi_peer_mapping_routes_correctly() {
1399        // Alice + Charlie send to Bob. Bob must distinguish the two by
1400        // GuidPrefix.
1401        let gov = parse_governance_xml(GOV_RTPS).unwrap();
1402        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1403        let charlie = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1404        let bob = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1405
1406        let alice_prefix: PeerKey = [1u8; 12];
1407        let charlie_prefix: PeerKey = [3u8; 12];
1408
1409        bob.register_remote_by_guid(
1410            alice_prefix,
1411            IdentityHandle(1),
1412            SharedSecretHandle(1),
1413            &alice.local_token().unwrap(),
1414        )
1415        .unwrap();
1416        bob.register_remote_by_guid(
1417            charlie_prefix,
1418            IdentityHandle(3),
1419            SharedSecretHandle(3),
1420            &charlie.local_token().unwrap(),
1421        )
1422        .unwrap();
1423
1424        let m_alice = fake_msg(b"from-alice");
1425        let m_charlie = fake_msg(b"from-charlie");
1426        let w_alice = alice.transform_outbound(&m_alice).unwrap();
1427        let w_charlie = charlie.transform_outbound(&m_charlie).unwrap();
1428
1429        assert_eq!(
1430            bob.transform_inbound_from(&alice_prefix, &w_alice).unwrap(),
1431            m_alice
1432        );
1433        assert_eq!(
1434            bob.transform_inbound_from(&charlie_prefix, &w_charlie)
1435                .unwrap(),
1436            m_charlie
1437        );
1438    }
1439
1440    #[test]
1441    fn tampered_wire_fails_tag_verify() {
1442        // Since the per-key_id resolution (transform_inbound resolves the key
1443        // via the transformation_key_id in the CryptoHeader, NOT via the
1444        // prefix-chosen slot) a wrong-prefix-but-valid wire decodes
1445        // correctly — Bob HAS Alice's key, the key_id points to it. This is intended
1446        // (sender attribution by crypto identity, not by a header prefix hint).
1447        // The SECURITY PROPERTY is GCM tag integrity: a tampered
1448        // wire MUST fail. That is exactly what this test verifies.
1449        let (alice, bob) = build_pair();
1450        let alice_prefix: PeerKey = [1u8; 12];
1451        bob.register_remote_by_guid(
1452            alice_prefix,
1453            IdentityHandle(1),
1454            SharedSecretHandle(1),
1455            &alice.local_token().unwrap(),
1456        )
1457        .unwrap();
1458
1459        let msg = fake_msg(b"from-alice");
1460        let wire = alice.transform_outbound(&msg).unwrap();
1461        // Legit: an unchanged wire decodes correctly.
1462        assert_eq!(
1463            bob.transform_inbound_from(&alice_prefix, &wire).unwrap(),
1464            msg
1465        );
1466
1467        // Tampered: flip one byte in the common_mac (GCM tag, in the SRTPS_POSTFIX).
1468        // The last 4 bytes are receiver_specific_macs._length(=0); the 16-byte
1469        // common_mac lies before that -> len-6 hits the tag. AES-GCM open MUST then
1470        // abort with a tag mismatch (Crypto) or a structure error (Wrapper).
1471        assert!(
1472            wire.len() > 30,
1473            "SRTPS wire too short for the tamper offset"
1474        );
1475        let mut tampered = wire.clone();
1476        let idx = tampered.len() - 6;
1477        tampered[idx] ^= 0xff;
1478        let err = bob
1479            .transform_inbound_from(&alice_prefix, &tampered)
1480            .unwrap_err();
1481        assert!(matches!(
1482            err,
1483            SecurityGateError::Wrapper(_) | SecurityGateError::Crypto(_)
1484        ));
1485    }
1486
1487    // -------------------------------------------------------------
1488    // RC1 stage 7 — receiver-specific MACs in the gate E2E
1489    // -------------------------------------------------------------
1490
1491    #[test]
1492    fn group_transform_one_ciphertext_three_macs_each_reader_decodes() {
1493        // DoD §stage 7 verbatim: 1 writer, 3 readers same suite,
1494        // different tokens → one ciphertext + 3 MACs; each
1495        // reader validates its own MAC.
1496        let gov = parse_governance_xml(GOV_RTPS).unwrap();
1497        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1498        let bob = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1499        let charlie = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1500        let dave = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1501
1502        let bob_prefix: PeerKey = [0xB1; 12];
1503        let charlie_prefix: PeerKey = [0xC1; 12];
1504        let dave_prefix: PeerKey = [0xD1; 12];
1505        let alice_prefix: PeerKey = [0xA1; 12];
1506
1507        // Alice registers all three receivers with their **own**
1508        // session keys (this is the per-reader SharedSecret).
1509        alice
1510            .register_remote_by_guid(
1511                bob_prefix,
1512                IdentityHandle(1),
1513                SharedSecretHandle(1),
1514                &bob.local_token().unwrap(),
1515            )
1516            .unwrap();
1517        alice
1518            .register_remote_by_guid(
1519                charlie_prefix,
1520                IdentityHandle(2),
1521                SharedSecretHandle(2),
1522                &charlie.local_token().unwrap(),
1523            )
1524            .unwrap();
1525        alice
1526            .register_remote_by_guid(
1527                dave_prefix,
1528                IdentityHandle(3),
1529                SharedSecretHandle(3),
1530                &dave.local_token().unwrap(),
1531            )
1532            .unwrap();
1533
1534        // Each receiver registers Alice under her Alice prefix —
1535        // so `transform_inbound_group` finds the sender side
1536        // for the AES-GCM unwrap.
1537        for recv in [&bob, &charlie, &dave] {
1538            recv.register_remote_by_guid(
1539                alice_prefix,
1540                IdentityHandle(10),
1541                SharedSecretHandle(10),
1542                &alice.local_token().unwrap(),
1543            )
1544            .unwrap();
1545        }
1546
1547        // Encode: 1 Ciphertext + 3 MACs.
1548        let plain = b"hetero-broadcast-e2e";
1549        let wire = alice
1550            .transform_outbound_group(&[bob_prefix, charlie_prefix, dave_prefix], plain)
1551            .unwrap();
1552
1553        // Each receiver decodes its variant identically — with
1554        // its own PeerKey as the match ID.
1555        let out_bob = bob
1556            .transform_inbound_group(&alice_prefix, &bob_prefix, &wire)
1557            .unwrap();
1558        let out_charlie = charlie
1559            .transform_inbound_group(&alice_prefix, &charlie_prefix, &wire)
1560            .unwrap();
1561        let out_dave = dave
1562            .transform_inbound_group(&alice_prefix, &dave_prefix, &wire)
1563            .unwrap();
1564        assert_eq!(out_bob, plain);
1565        assert_eq!(out_charlie, plain);
1566        assert_eq!(out_dave, plain);
1567    }
1568
1569    #[test]
1570    fn group_transform_rogue_receiver_without_mac_rejects() {
1571        // A 4th receiver (Eve) is NOT in Alice's MAC list.
1572        // Eve obtained Alice's token via a side channel (or
1573        // eavesdropping) and tries to decode. Her own HMAC
1574        // key in Eve's `ensure_local()` slot matches no MAC
1575        // entry → crypto fail.
1576        let gov = parse_governance_xml(GOV_RTPS).unwrap();
1577        let alice = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1578        let bob = SharedSecurityGate::new(0, gov.clone(), Box::new(AesGcmCryptoPlugin::new()));
1579        let eve = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1580
1581        let alice_prefix: PeerKey = [0xA2; 12];
1582        let bob_prefix: PeerKey = [0xB2; 12];
1583        alice
1584            .register_remote_by_guid(
1585                bob_prefix,
1586                IdentityHandle(1),
1587                SharedSecretHandle(1),
1588                &bob.local_token().unwrap(),
1589            )
1590            .unwrap();
1591        // Eve knows Alice's token (attacker scenario), registers her.
1592        eve.register_remote_by_guid(
1593            alice_prefix,
1594            IdentityHandle(10),
1595            SharedSecretHandle(10),
1596            &alice.local_token().unwrap(),
1597        )
1598        .unwrap();
1599
1600        let wire = alice
1601            .transform_outbound_group(&[bob_prefix], b"confidential")
1602            .unwrap();
1603
1604        let eve_prefix: PeerKey = [0xEE; 12];
1605        let err = eve
1606            .transform_inbound_group(&alice_prefix, &eve_prefix, &wire)
1607            .unwrap_err();
1608        assert!(
1609            matches!(
1610                err,
1611                SecurityGateError::Crypto(_) | SecurityGateError::Wrapper(_)
1612            ),
1613            "Eve without a MAC entry must drop, got: {err:?}"
1614        );
1615    }
1616
1617    #[test]
1618    fn group_transform_unknown_peer_is_policy_violation() {
1619        // Alice tries to encode for an unregistered peer
1620        // → PolicyViolation.
1621        let alice = SharedSecurityGate::new(
1622            0,
1623            parse_governance_xml(GOV_RTPS).unwrap(),
1624            Box::new(AesGcmCryptoPlugin::new()),
1625        );
1626        let unregistered: PeerKey = [0x99; 12];
1627        let err = alice
1628            .transform_outbound_group(&[unregistered], b"x")
1629            .unwrap_err();
1630        assert!(matches!(err, SecurityGateError::PolicyViolation(_)));
1631    }
1632
1633    #[test]
1634    fn forget_remote_removes_mapping() {
1635        let (alice, bob) = build_pair();
1636        let alice_prefix: PeerKey = [0xAA; 12];
1637        bob.register_remote_by_guid(
1638            alice_prefix,
1639            IdentityHandle(1),
1640            SharedSecretHandle(1),
1641            &alice.local_token().unwrap(),
1642        )
1643        .unwrap();
1644        assert!(bob.slot_for(&alice_prefix).unwrap().is_some());
1645        bob.forget_remote(&alice_prefix).unwrap();
1646        assert!(bob.slot_for(&alice_prefix).unwrap().is_none());
1647    }
1648
1649    // -------------------------------------------------------------
1650    // RC1 stage 4a — transform_outbound_for
1651    // -------------------------------------------------------------
1652
1653    #[test]
1654    fn transform_outbound_for_none_is_passthrough_even_on_protected_domain() {
1655        // The domain is ENCRYPT per governance, but the caller requests
1656        // per-reader None — must deliver plaintext (this is the
1657        // heterogeneous case).
1658        let gate = SharedSecurityGate::new(
1659            0,
1660            parse_governance_xml(GOV_RTPS).unwrap(),
1661            Box::new(AesGcmCryptoPlugin::new()),
1662        );
1663        let peer_key: PeerKey = [0xBB; 12];
1664        let msg = fake_msg(b"[plain-for-legacy]");
1665        let out = gate
1666            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::None)
1667            .unwrap();
1668        assert_eq!(out, msg, "None level must passthrough byte-identical");
1669    }
1670
1671    #[test]
1672    fn transform_outbound_for_encrypt_produces_srtps_wire() {
1673        let gate = SharedSecurityGate::new(
1674            0,
1675            parse_governance_xml(GOV_RTPS).unwrap(),
1676            Box::new(AesGcmCryptoPlugin::new()),
1677        );
1678        let peer_key: PeerKey = [0xCC; 12];
1679        let msg = fake_msg(b"[enc-for-secure]");
1680        let wire = gate
1681            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::Encrypt)
1682            .unwrap();
1683        // The output must be longer than plain (SRTPS overhead) and start at the
1684        // SRTPS_PREFIX byte after the RTPS header.
1685        assert!(wire.len() > msg.len());
1686        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
1687    }
1688
1689    #[test]
1690    fn transform_outbound_for_sign_also_uses_srtps_encoder() {
1691        // The sign level today uses the same encoder as Encrypt (v1.4
1692        // plugin status). All that matters: the output is NOT plaintext.
1693        let gate = SharedSecurityGate::new(
1694            0,
1695            parse_governance_xml(GOV_RTPS).unwrap(),
1696            Box::new(AesGcmCryptoPlugin::new()),
1697        );
1698        let peer_key: PeerKey = [0xDD; 12];
1699        let msg = fake_msg(b"[sig-for-fast]");
1700        let wire = gate
1701            .transform_outbound_for(&peer_key, &msg, ProtectionLevel::Sign)
1702            .unwrap();
1703        assert_ne!(wire, msg, "Sign must not be byte-identical to plain");
1704        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
1705    }
1706
1707    #[test]
1708    fn transform_outbound_for_heterogeneous_three_readers() {
1709        // 1 writer → 3 readers (legacy/sign/encrypt). Each output is
1710        // individually different — that is the core of RC1.
1711        let gate = SharedSecurityGate::new(
1712            0,
1713            parse_governance_xml(GOV_RTPS).unwrap(),
1714            Box::new(AesGcmCryptoPlugin::new()),
1715        );
1716        let msg = fake_msg(b"[broadcast]");
1717        let legacy = gate
1718            .transform_outbound_for(&[1; 12], &msg, ProtectionLevel::None)
1719            .unwrap();
1720        let fast = gate
1721            .transform_outbound_for(&[2; 12], &msg, ProtectionLevel::Sign)
1722            .unwrap();
1723        let secure = gate
1724            .transform_outbound_for(&[3; 12], &msg, ProtectionLevel::Encrypt)
1725            .unwrap();
1726        assert_eq!(legacy, msg, "the legacy reader gets plain");
1727        assert_ne!(fast, msg, "the fast reader gets SRTPS-wrapped");
1728        assert_ne!(secure, msg, "the secure reader gets SRTPS-wrapped");
1729        // Sign and encrypt packets are not byte-identical — even
1730        // when the same encoder is used, each encode uses a
1731        // fresh nonce counter.
1732        assert_ne!(fast, secure, "per-reader encoding must differ each time");
1733    }
1734
1735    // -------------------------------------------------------------
1736    // RC1 stage 5 — classify_inbound + allow_unauthenticated
1737    // -------------------------------------------------------------
1738
1739    const GOV_NONE: &str = r#"
1740<domain_access_rules>
1741  <domain_rule>
1742    <domains><id>0</id></domains>
1743    <rtps_protection_kind>NONE</rtps_protection_kind>
1744    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
1745  </domain_rule>
1746</domain_access_rules>
1747"#;
1748    const GOV_ENCRYPT_ALLOW_UNAUTH: &str = r#"
1749<domain_access_rules>
1750  <domain_rule>
1751    <domains><id>0</id></domains>
1752    <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
1753    <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
1754    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
1755  </domain_rule>
1756</domain_access_rules>
1757"#;
1758
1759    #[test]
1760    fn allow_unauthenticated_default_false_without_element() {
1761        let gate = SharedSecurityGate::new(
1762            0,
1763            parse_governance_xml(GOV_RTPS).unwrap(),
1764            Box::new(AesGcmCryptoPlugin::new()),
1765        );
1766        assert!(!gate.allow_unauthenticated().unwrap());
1767    }
1768
1769    #[test]
1770    fn allow_unauthenticated_reads_true_when_set() {
1771        let gate = SharedSecurityGate::new(
1772            0,
1773            parse_governance_xml(GOV_ENCRYPT_ALLOW_UNAUTH).unwrap(),
1774            Box::new(AesGcmCryptoPlugin::new()),
1775        );
1776        assert!(gate.allow_unauthenticated().unwrap());
1777    }
1778
1779    #[test]
1780    fn allow_unauthenticated_defaults_false_for_unknown_domain() {
1781        let gate = SharedSecurityGate::new(
1782            99,
1783            parse_governance_xml(GOV_RTPS).unwrap(),
1784            Box::new(AesGcmCryptoPlugin::new()),
1785        );
1786        assert!(!gate.allow_unauthenticated().unwrap());
1787    }
1788
1789    #[test]
1790    fn classify_inbound_rejects_truncated_datagram() {
1791        let gate = SharedSecurityGate::new(
1792            0,
1793            parse_governance_xml(GOV_NONE).unwrap(),
1794            Box::new(AesGcmCryptoPlugin::new()),
1795        );
1796        let verdict = gate.classify_inbound(&[0u8; 10], &NetInterface::Wan);
1797        assert_eq!(verdict, InboundVerdict::Malformed);
1798        assert_eq!(verdict.category(), "inbound.malformed");
1799    }
1800
1801    #[test]
1802    fn classify_inbound_plain_on_none_domain_accepts() {
1803        let gate = SharedSecurityGate::new(
1804            0,
1805            parse_governance_xml(GOV_NONE).unwrap(),
1806            Box::new(AesGcmCryptoPlugin::new()),
1807        );
1808        let msg = fake_msg(b"[plain-hello]");
1809        match gate.classify_inbound(&msg, &NetInterface::Wan) {
1810            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1811            other => panic!("expected Accept, got {other:?}"),
1812        }
1813    }
1814
1815    #[test]
1816    fn classify_inbound_plain_on_protected_domain_is_legacy_blocked() {
1817        // The domain requires ENCRYPT, allow_unauth = false (default).
1818        let gate = SharedSecurityGate::new(
1819            0,
1820            parse_governance_xml(GOV_RTPS).unwrap(),
1821            Box::new(AesGcmCryptoPlugin::new()),
1822        );
1823        let msg = fake_msg(b"[legacy-on-encrypted]");
1824        let verdict = gate.classify_inbound(&msg, &NetInterface::Wan);
1825        assert_eq!(verdict, InboundVerdict::LegacyBlocked);
1826        assert_eq!(verdict.category(), "inbound.legacy_blocked");
1827        assert!(!verdict.is_accept());
1828    }
1829
1830    #[test]
1831    fn classify_inbound_plain_on_protected_domain_with_allow_unauth_accepts() {
1832        // DoD test: a legacy peer is accepted when governance
1833        // explicitly allows it.
1834        let gate = SharedSecurityGate::new(
1835            0,
1836            parse_governance_xml(GOV_ENCRYPT_ALLOW_UNAUTH).unwrap(),
1837            Box::new(AesGcmCryptoPlugin::new()),
1838        );
1839        let msg = fake_msg(b"[legacy-allowed]");
1840        match gate.classify_inbound(&msg, &NetInterface::Wan) {
1841            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1842            other => panic!("expected Accept (allow_unauthenticated=true), got {other:?}"),
1843        }
1844    }
1845
1846    #[test]
1847    fn classify_inbound_plain_on_loopback_accepts_even_on_protected_domain() {
1848        // Arch doc §2.1: "intra-host loopback: plain (does not leave the
1849        // network)". Protected domain, but interface=Loopback →
1850        // plaintext is accepted spec-conform.
1851        let gate = SharedSecurityGate::new(
1852            0,
1853            parse_governance_xml(GOV_RTPS).unwrap(),
1854            Box::new(AesGcmCryptoPlugin::new()),
1855        );
1856        let msg = fake_msg(b"[loopback-plain]");
1857        match gate.classify_inbound(&msg, &NetInterface::Loopback) {
1858            InboundVerdict::Accept(out) => assert_eq!(out, msg),
1859            other => panic!("expected Loopback-Accept, got {other:?}"),
1860        }
1861    }
1862
1863    #[test]
1864    fn classify_inbound_srtps_from_unknown_peer_is_policy_violation() {
1865        // No register_remote_by_guid — the peer is unknown.
1866        let (alice, bob) = build_pair();
1867        // Alice sends us an SRTPS wrapper, but bob has not registered
1868        // alice → classify must report PolicyViolation.
1869        let msg = fake_msg(b"[from-unknown]");
1870        let wire = alice.transform_outbound(&msg).unwrap();
1871        let verdict = bob.classify_inbound(&wire, &NetInterface::Wan);
1872        assert!(
1873            matches!(verdict, InboundVerdict::PolicyViolation(_)),
1874            "expected PolicyViolation, got {verdict:?}"
1875        );
1876        assert_eq!(verdict.category(), "inbound.policy_violation");
1877    }
1878
1879    #[test]
1880    fn classify_inbound_srtps_from_known_peer_accepts() {
1881        let (alice, bob) = build_pair();
1882        let alice_prefix: PeerKey = [0xAA; 12];
1883        bob.register_remote_by_guid(
1884            alice_prefix,
1885            IdentityHandle(1),
1886            SharedSecretHandle(1),
1887            &alice.local_token().unwrap(),
1888        )
1889        .unwrap();
1890        let msg = fake_msg(b"[authed-peer]");
1891        // The sender must carry the same GuidPrefix in the header so
1892        // classify_inbound finds the peer key.
1893        let mut hdr_msg = Vec::with_capacity(msg.len());
1894        hdr_msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
1895        hdr_msg.extend_from_slice(&alice_prefix);
1896        hdr_msg.extend_from_slice(b"payload-body");
1897        let wire = alice.transform_outbound(&hdr_msg).unwrap();
1898        match bob.classify_inbound(&wire, &NetInterface::Wan) {
1899            InboundVerdict::Accept(_) => {}
1900            other => panic!("expected Accept, got {other:?}"),
1901        }
1902    }
1903
1904    #[test]
1905    fn classify_inbound_srtps_with_wrong_key_is_crypto_error() {
1906        // Alice + Charlie encode with different keys; Bob has
1907        // Alice registered but gets Charlie's bytes under Alice's
1908        // peer_key → crypto tag mismatch.
1909        let (alice, bob) = build_pair();
1910        let gov = parse_governance_xml(GOV_RTPS).unwrap();
1911        let charlie = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
1912
1913        let alice_prefix: PeerKey = [0xAA; 12];
1914        bob.register_remote_by_guid(
1915            alice_prefix,
1916            IdentityHandle(1),
1917            SharedSecretHandle(1),
1918            &alice.local_token().unwrap(),
1919        )
1920        .unwrap();
1921
1922        // Charlie encodes with Alice's prefix in the header (MITM simulation).
1923        let mut body = Vec::new();
1924        body.extend_from_slice(b"RTPS\x02\x05\x01\x02");
1925        body.extend_from_slice(&alice_prefix);
1926        body.extend_from_slice(b"mitm-try");
1927        let spoofed = charlie.transform_outbound(&body).unwrap();
1928
1929        let verdict = bob.classify_inbound(&spoofed, &NetInterface::Wan);
1930        assert!(
1931            matches!(verdict, InboundVerdict::CryptoError(_)),
1932            "expected CryptoError, got {verdict:?}"
1933        );
1934        assert_eq!(verdict.category(), "inbound.crypto_error");
1935    }
1936
1937    #[test]
1938    fn transform_outbound_for_is_decodable_with_registered_token() {
1939        // E2E: Alice serializes via `transform_outbound_for`, Bob
1940        // registers Alice's token and decodes successfully.
1941        let (alice, bob) = build_pair();
1942        let alice_prefix: PeerKey = [0xAA; 12];
1943        bob.register_remote_by_guid(
1944            alice_prefix,
1945            IdentityHandle(1),
1946            SharedSecretHandle(1),
1947            &alice.local_token().unwrap(),
1948        )
1949        .unwrap();
1950        let msg = fake_msg(b"[hetero-e2e]");
1951        let wire = alice
1952            .transform_outbound_for(&[9; 12], &msg, ProtectionLevel::Encrypt)
1953            .unwrap();
1954        let back = bob.transform_inbound_from(&alice_prefix, &wire).unwrap();
1955        assert_eq!(back, msg);
1956    }
1957
1958    // -------------------------------------------------------------
1959    // FU2 S1.2 — dual-key at the gate: Kx channel (VolatileSecure) +
1960    // data key via token. Both from the same handshake secret.
1961    // -------------------------------------------------------------
1962
1963    struct FixedSecret;
1964    impl zerodds_security::authentication::SharedSecretProvider for FixedSecret {
1965        fn get_shared_secret(
1966            &self,
1967            _h: zerodds_security::authentication::SharedSecretHandle,
1968        ) -> Option<Vec<u8>> {
1969            Some(alloc::vec![0x77u8; 32])
1970        }
1971    }
1972
1973    fn build_kx_pair() -> (SharedSecurityGate, SharedSecurityGate) {
1974        let mk = || {
1975            SharedSecurityGate::new(
1976                0,
1977                parse_governance_xml(GOV_RTPS).unwrap(),
1978                Box::new(AesGcmCryptoPlugin::with_secret_provider(
1979                    zerodds_security_crypto::Suite::Aes128Gcm,
1980                    Arc::new(FixedSecret)
1981                        as Arc<dyn zerodds_security::authentication::SharedSecretProvider>,
1982                )),
1983            )
1984        };
1985        (mk(), mk())
1986    }
1987
1988    #[test]
1989    fn kx_channel_round_trips_through_gate() {
1990        let (alice, bob) = build_kx_pair();
1991        let alice_prefix: PeerKey = [0xAA; 12];
1992        let bob_prefix: PeerKey = [0xBB; 12];
1993        alice
1994            .register_remote_by_guid_from_secret(
1995                bob_prefix,
1996                IdentityHandle(2),
1997                SharedSecretHandle(1),
1998            )
1999            .unwrap();
2000        bob.register_remote_by_guid_from_secret(
2001            alice_prefix,
2002            IdentityHandle(1),
2003            SharedSecretHandle(1),
2004        )
2005        .unwrap();
2006        // VolatileSecure payload (ParticipantCryptoToken) Kx-protected.
2007        let token_blob = b"participant-crypto-token-payload";
2008        let wire = alice
2009            .transform_kx_outbound_for(&bob_prefix, token_blob)
2010            .unwrap();
2011        let back = bob.transform_kx_inbound_from(&alice_prefix, &wire).unwrap();
2012        assert_eq!(back, token_blob);
2013    }
2014
2015    #[test]
2016    fn data_round_trips_via_token_after_kx_register() {
2017        let (alice, bob) = build_kx_pair();
2018        let alice_prefix: PeerKey = [0xAA; 12];
2019        let bob_prefix: PeerKey = [0xBB; 12];
2020        // Both register the Kx key (no token).
2021        alice
2022            .register_remote_by_guid_from_secret(
2023                bob_prefix,
2024                IdentityHandle(2),
2025                SharedSecretHandle(1),
2026            )
2027            .unwrap();
2028        bob.register_remote_by_guid_from_secret(
2029            alice_prefix,
2030            IdentityHandle(1),
2031            SharedSecretHandle(1),
2032        )
2033        .unwrap();
2034        // Data token exchange: bob installs alice's local data key.
2035        bob.set_remote_data_token_by_guid(&alice_prefix, &alice.local_token().unwrap())
2036            .unwrap();
2037        // Secured DATA: alice encrypts (local key), bob decrypts (token key).
2038        let msg = fake_msg(b"[secured-user-data]");
2039        let wire = alice
2040            .transform_outbound_for(&bob_prefix, &msg, ProtectionLevel::Encrypt)
2041            .unwrap();
2042        let back = bob.transform_inbound_from(&alice_prefix, &wire).unwrap();
2043        assert_eq!(back, msg);
2044    }
2045}