Skip to main content

zerodds_discovery/security/
stack.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//
4// zerodds-lint: allow no_dyn_in_safe
5// Rationale: the DDS-Security plugins (Authentication/AccessControl/Crypto)
6// are chosen config-driven at runtime (PKI vs. stub, etc.) — that is
7// inherently dynamic polymorphism. `dyn ...Plugin` behind Arc<Mutex<>> is
8// here the spec-conformant plugin pattern, not replaceable by concrete generics.
9//! `SecurityBuiltinStack` — bundles the two security builtin topic-
10//! Endpoint pairs in one structure.
11//!
12//! - `DCPSParticipantStatelessMessage` (auth handshake, BestEffort).
13//! - `DCPSParticipantVolatileMessageSecure` (crypto key exchange, Reliable).
14//!
15//! Instantiated by the participant wiring (DCPS layer) as soon as a
16//! security plugin is registered and discovery bits 22..25 are announced
17//! in the `BuiltinEndpointSet`. The stack maintains the reader/writer
18//! proxies per remote participant — `handle_remote_endpoints` is called
19//! from the SPDP hot path as soon as a peer with the corresponding bits
20//! is discovered.
21
22extern crate alloc;
23use alloc::vec::Vec;
24use core::time::Duration;
25
26use zerodds_rtps::error::WireError;
27use zerodds_rtps::message_builder::OutboundDatagram;
28use zerodds_rtps::reader_proxy::ReaderProxy;
29use zerodds_rtps::wire_types::{EntityId, Guid, GuidPrefix, Locator, VendorId};
30use zerodds_rtps::writer_proxy::WriterProxy;
31
32use crate::capabilities::PeerCapabilities;
33use crate::security::stateless::{StatelessMessageReader, StatelessMessageWriter};
34use crate::security::volatile_secure::{VolatileSecureMessageReader, VolatileSecureMessageWriter};
35use crate::spdp::DiscoveredParticipant;
36
37#[cfg(feature = "std")]
38use alloc::collections::BTreeMap;
39#[cfg(feature = "std")]
40use alloc::sync::Arc;
41#[cfg(feature = "std")]
42use std::sync::Mutex;
43
44#[cfg(feature = "std")]
45use zerodds_security::authentication::{
46    AuthenticationPlugin, HandshakeHandle, HandshakeStepOutcome, IdentityHandle, SharedSecretHandle,
47};
48#[cfg(feature = "std")]
49use zerodds_security::error::{SecurityError, SecurityErrorKind, SecurityResult};
50#[cfg(feature = "std")]
51use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
52#[cfg(feature = "std")]
53use zerodds_security::token::DataHolder;
54
55/// Result of a processed incoming handshake step: outbound datagrams
56/// plus — once derived on this side — the `(remote_identity,
57/// shared_secret)` tuple for the crypto gate.
58#[cfg(feature = "std")]
59pub type HandshakeStepResult = (
60    Vec<OutboundDatagram>,
61    Option<(IdentityHandle, SharedSecretHandle)>,
62);
63
64/// Locks the shared auth plugin; a poisoned mutex (panic in another
65/// handshake thread) is reported as an `Internal` error instead of
66/// panicking itself.
67#[cfg(feature = "std")]
68fn lock_auth<'a>(
69    auth: &'a Arc<Mutex<dyn AuthenticationPlugin + 'static>>,
70) -> SecurityResult<std::sync::MutexGuard<'a, dyn AuthenticationPlugin + 'static>> {
71    auth.lock()
72        .map_err(|_| SecurityError::new(SecurityErrorKind::Internal, "auth mutex poisoned"))
73}
74
75/// Per-peer state of the auth handshake (FU2 driver).
76///
77/// Created as soon as a peer with stateless bits is discovered via SPDP
78/// and `begin_handshake_with` has been called. The `handshake` handle
79/// only exists after the first plugin step (initiator:
80/// `begin_handshake_request`, replier: `begin_handshake_reply`).
81#[cfg(feature = "std")]
82#[derive(Debug)]
83struct PeerHandshake {
84    /// Validated remote identity (from `validate_remote_identity`).
85    remote_identity: IdentityHandle,
86    /// 16-byte remote participant GUID (destination of the tokens).
87    remote_guid: [u8; 16],
88    /// Running handshake in the plugin — `None` until the first step.
89    handshake: Option<HandshakeHandle>,
90    /// Sequence-number counter for outbound `message_identity`.
91    next_sn: i64,
92    /// Secret already reported to the caller — idempotency guard.
93    secret: Option<SharedSecretHandle>,
94    /// FU2 S3: last sent initiator message (AUTH_REQUEST). Re-emitted on
95    /// every repeated `begin_handshake_with` (periodic SPDP beacon) as
96    /// long as the secret is missing — makes the best-effort stateless
97    /// handshake robust against lost initial messages.
98    last_request: Option<ParticipantGenericMessage>,
99}
100
101/// Bundle of the four security builtin endpoints.
102pub struct SecurityBuiltinStack {
103    local_prefix: GuidPrefix,
104    /// Stateless auth writer (Spec §7.4.4).
105    pub stateless_writer: StatelessMessageWriter,
106    /// Stateless auth reader.
107    pub stateless_reader: StatelessMessageReader,
108    /// Volatile-Secure writer (Spec §7.4.5).
109    pub volatile_writer: VolatileSecureMessageWriter,
110    /// Volatile-Secure reader.
111    pub volatile_reader: VolatileSecureMessageReader,
112    /// Auth plugin for the identity handshake. `None` = pure proxy
113    /// plumbing without a handshake driver (backward-compat). Shared via
114    /// `Arc<Mutex>` with the crypto plugin's `SharedSecretProvider`
115    /// (security-runtime Gap 1), so that the secret derived after the
116    /// handshake is resolvable there.
117    #[cfg(feature = "std")]
118    auth: Option<Arc<Mutex<dyn AuthenticationPlugin>>>,
119    /// Local validated identity (from `validate_local_identity`).
120    #[cfg(feature = "std")]
121    local_identity: Option<IdentityHandle>,
122    /// Local 16-byte participant GUID (source of the `message_identity`).
123    #[cfg(feature = "std")]
124    local_guid: [u8; 16],
125    /// Handshake state per remote participant.
126    #[cfg(feature = "std")]
127    handshakes: BTreeMap<GuidPrefix, PeerHandshake>,
128    /// Peer `VendorId` per remote prefix (from the SPDP RTPS header). Controls
129    /// vendor-specific handshake quirks (e.g. OpenDDS' NUL-terminated
130    /// `c.dsign_algo`/`c.kagree_algo`). Maintained by the discovery layer via
131    /// [`Self::note_remote_vendor`].
132    #[cfg(feature = "std")]
133    remote_vendors: BTreeMap<GuidPrefix, VendorId>,
134    /// Timestamp of the last handshake resend (throttle). The
135    /// `DCPSParticipantStatelessMessage` writer is per spec (DDS-Security
136    /// §7.4.4) BEST_EFFORT; the reliability of the 3-message handshake
137    /// comes from periodic re-send of the pending message until the secret
138    /// is established. That is the spec protocol, NOT a workaround for the
139    /// (separately fixed) discovery coupling.
140    #[cfg(feature = "std")]
141    last_handshake_resend: Duration,
142}
143
144impl core::fmt::Debug for SecurityBuiltinStack {
145    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
146        let mut dbg = f.debug_struct("SecurityBuiltinStack");
147        dbg.field("local_prefix", &self.local_prefix)
148            .field("stateless_writer", &self.stateless_writer)
149            .field("stateless_reader", &self.stateless_reader)
150            .field("volatile_writer", &self.volatile_writer)
151            .field("volatile_reader", &self.volatile_reader);
152        #[cfg(feature = "std")]
153        dbg.field("auth", &self.auth.is_some())
154            .field("local_identity", &self.local_identity)
155            .field("handshakes", &self.handshakes);
156        dbg.finish()
157    }
158}
159
160impl SecurityBuiltinStack {
161    /// Creates a fresh stack without remote proxies and without a
162    /// handshake driver (pure proxy plumbing, backward-compat).
163    #[must_use]
164    pub fn new(local_prefix: GuidPrefix, vendor_id: VendorId) -> Self {
165        Self {
166            local_prefix,
167            stateless_writer: StatelessMessageWriter::new(local_prefix, vendor_id),
168            stateless_reader: StatelessMessageReader::new(local_prefix, vendor_id),
169            volatile_writer: VolatileSecureMessageWriter::new(local_prefix, vendor_id),
170            volatile_reader: VolatileSecureMessageReader::new(local_prefix, vendor_id),
171            #[cfg(feature = "std")]
172            auth: None,
173            #[cfg(feature = "std")]
174            local_identity: None,
175            #[cfg(feature = "std")]
176            local_guid: [0; 16],
177            #[cfg(feature = "std")]
178            handshakes: BTreeMap::new(),
179            #[cfg(feature = "std")]
180            remote_vendors: BTreeMap::new(),
181            #[cfg(feature = "std")]
182            last_handshake_resend: Duration::ZERO,
183        }
184    }
185
186    /// Creates a stack WITH a handshake driver (FU2). The `auth` plugin
187    /// is shared via `Arc<Mutex>` — the same instance must be attached as
188    /// the `SharedSecretProvider` on the crypto plugin (security-runtime
189    /// Gap 1), so that the secret returned after `Complete` is resolvable
190    /// there.
191    ///
192    /// `local_identity` comes from `validate_local_identity`,
193    /// `local_guid` is the 16-byte participant GUID of this stack.
194    #[cfg(feature = "std")]
195    #[must_use]
196    pub fn with_auth(
197        local_prefix: GuidPrefix,
198        vendor_id: VendorId,
199        auth: Arc<Mutex<dyn AuthenticationPlugin>>,
200        local_identity: IdentityHandle,
201        local_guid: [u8; 16],
202    ) -> Self {
203        Self {
204            local_prefix,
205            stateless_writer: StatelessMessageWriter::new(local_prefix, vendor_id),
206            stateless_reader: StatelessMessageReader::new(local_prefix, vendor_id),
207            volatile_writer: VolatileSecureMessageWriter::new(local_prefix, vendor_id),
208            volatile_reader: VolatileSecureMessageReader::new(local_prefix, vendor_id),
209            auth: Some(auth),
210            local_identity: Some(local_identity),
211            local_guid,
212            handshakes: BTreeMap::new(),
213            remote_vendors: BTreeMap::new(),
214            last_handshake_resend: Duration::ZERO,
215        }
216    }
217
218    /// Local GuidPrefix.
219    #[must_use]
220    pub fn local_prefix(&self) -> GuidPrefix {
221        self.local_prefix
222    }
223
224    /// Wires reader/writer proxies based on the BuiltinEndpointSet bits
225    /// announced by the peer (Spec §7.4.7.1):
226    ///
227    /// - Bits 22+23 (`PARTICIPANT_STATELESS_MESSAGE_*`) → stateless slot
228    /// - Bits 24+25 (`PARTICIPANT_VOLATILE_MESSAGE_SECURE_*`) → volatile slot
229    ///
230    /// We route over `metatraffic_unicast_locator` (PID 0x0032),
231    /// falling back to `default_unicast_locator`. Self-discovery
232    /// (`peer.sender_prefix == self.local_prefix`) is ignored.
233    pub fn handle_remote_endpoints(&mut self, peer: &DiscoveredParticipant) {
234        if peer.sender_prefix == self.local_prefix {
235            return;
236        }
237        let caps = PeerCapabilities::from_bits(peer.data.builtin_endpoint_set);
238        if !caps.has_stateless_auth && !caps.has_volatile_secure {
239            return;
240        }
241        let unicast: Vec<Locator> = peer
242            .data
243            .metatraffic_unicast_locator
244            .or(peer.data.default_unicast_locator)
245            .into_iter()
246            .collect();
247        let remote_prefix = peer.sender_prefix;
248
249        if caps.has_stateless_auth {
250            self.stateless_writer.add_reader_proxy(ReaderProxy::new(
251                Guid::new(
252                    remote_prefix,
253                    EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER,
254                ),
255                unicast.clone(),
256                Vec::new(),
257                false,
258            ));
259            self.stateless_reader.add_writer_proxy(WriterProxy::new(
260                Guid::new(
261                    remote_prefix,
262                    EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER,
263                ),
264                unicast.clone(),
265                Vec::new(),
266                false,
267            ));
268        }
269
270        if caps.has_volatile_secure {
271            self.volatile_writer.add_reader_proxy(ReaderProxy::new(
272                Guid::new(
273                    remote_prefix,
274                    EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER,
275                ),
276                unicast.clone(),
277                Vec::new(),
278                true,
279            ));
280            self.volatile_reader.add_writer_proxy(WriterProxy::new(
281                Guid::new(
282                    remote_prefix,
283                    EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER,
284                ),
285                unicast,
286                Vec::new(),
287                true,
288            ));
289        }
290    }
291
292    /// Cleanup after an SPDP lease timeout: removes all proxies of this
293    /// prefix. Returns `(stateless_pairs_removed,
294    /// volatile_pairs_removed)`.
295    pub fn on_participant_lost(&mut self, prefix: GuidPrefix) -> (usize, usize) {
296        let mut stateless = 0usize;
297        let mut volatile = 0usize;
298        #[cfg(feature = "std")]
299        self.handshakes.remove(&prefix);
300        if self
301            .stateless_writer
302            .remove_reader_proxy(Guid::new(
303                prefix,
304                EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER,
305            ))
306            .is_some()
307        {
308            stateless += 1;
309        }
310        self.stateless_reader.remove_writer_proxy(Guid::new(
311            prefix,
312            EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER,
313        ));
314        if self
315            .volatile_writer
316            .remove_reader_proxy(Guid::new(
317                prefix,
318                EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER,
319            ))
320            .is_some()
321        {
322            volatile += 1;
323        }
324        self.volatile_reader.remove_writer_proxy(Guid::new(
325            prefix,
326            EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER,
327        ));
328        (stateless, volatile)
329    }
330
331    /// Tick over all endpoints. Returns HEARTBEATs/resends from the
332    /// volatile writer plus ACKNACK/NACK_FRAG from the volatile reader.
333    /// Stateless has no tick (BestEffort, no resend state).
334    ///
335    /// # Errors
336    /// Wire encode errors from the reliable layer.
337    pub fn poll(&mut self, now: Duration) -> Result<Vec<OutboundDatagram>, WireError> {
338        let mut out = Vec::new();
339        out.extend(self.volatile_writer.tick(now)?);
340        out.extend(self.volatile_reader.tick_outbound(now)?);
341        // DDS-Security §7.4.4: the stateless handshake channel is BEST_EFFORT;
342        // reliability comes from periodically re-sending the pending handshake
343        // message (initiator: AUTH_REQUEST, replier: cached reply) until the
344        // secret is derived. The receiver is idempotent (duplicate request →
345        // cached reply, no DH regeneration). Without this re-send, reply/final
346        // are lost over the lossy channel and the handshake stalls —
347        // independent of the separately fixed discovery coupling.
348        #[cfg(feature = "std")]
349        if now.saturating_sub(self.last_handshake_resend) >= Duration::from_millis(500) {
350            self.last_handshake_resend = now;
351            let pending: Vec<ParticipantGenericMessage> = self
352                .handshakes
353                .values()
354                .filter(|p| p.secret.is_none())
355                .filter_map(|p| p.last_request.clone())
356                .collect();
357            for msg in &pending {
358                out.extend(self.stateless_writer.write(msg)?);
359            }
360        }
361        Ok(out)
362    }
363
364    /// Returns the `SharedSecretHandle` of a peer once the handshake is
365    /// complete on this side (otherwise `None`). FU2: lets the DCPS layer
366    /// (and tests) check whether a peer has been authenticated.
367    #[cfg(feature = "std")]
368    #[must_use]
369    pub fn peer_secret(&self, remote_prefix: GuidPrefix) -> Option<SharedSecretHandle> {
370        self.handshakes.get(&remote_prefix).and_then(|p| p.secret)
371    }
372
373    /// All peers whose handshake is complete on this side (`secret` set).
374    /// Lets the DCPS tick send per-endpoint crypto tokens to every
375    /// authenticated peer as soon as the local user endpoints exist (FU2
376    /// step 6b — the handshake completes before the benchmark creates the
377    /// user endpoints).
378    #[cfg(feature = "std")]
379    #[must_use]
380    pub fn completed_peer_prefixes(&self) -> Vec<GuidPrefix> {
381        self.handshakes
382            .iter()
383            .filter(|(_, p)| p.secret.is_some())
384            .map(|(prefix, _)| *prefix)
385            .collect()
386    }
387
388    /// FU2 handshake driver: starts the auth handshake with a freshly
389    /// discovered peer (Spec §8.3.2). Called from the SPDP hot path after
390    /// `handle_remote_endpoints` as soon as the peer's
391    /// `PID_IDENTITY_TOKEN` is available.
392    ///
393    /// Validates the remote identity, determines the role via GUID
394    /// comparison (SMALLER local GUID = initiator) and — if initiator —
395    /// sends the `AUTH_REQUEST` over the stateless writer. The replier
396    /// only creates its peer state and waits for the request.
397    ///
398    /// Without a configured auth plugin (`new` instead of `with_auth`)
399    /// this is a no-op (empty datagram list).
400    ///
401    /// The initiator convention (smaller local GUID ⇒ initiator) is
402    /// cyclone-verified (c2c handshake FSM trace: the smaller GUID sends
403    /// the request, the larger one replies). Both sides MUST choose the
404    /// same direction, otherwise neither/both initiate.
405    ///
406    /// Records the `VendorId` of a remote participant (from the SPDP RTPS
407    /// header) for vendor-specific handshake quirks (e.g. OpenDDS'
408    /// NUL-terminated algorithm strings). Should be set BEFORE
409    /// [`Self::begin_handshake_with`]/[`Self::on_stateless_message`];
410    /// if it is not, the NUL-free spec/FastDDS/Cyclone default applies.
411    #[cfg(feature = "std")]
412    pub fn note_remote_vendor(&mut self, remote_prefix: GuidPrefix, vendor: VendorId) {
413        self.remote_vendors.insert(remote_prefix, vendor);
414    }
415
416    /// # Errors
417    /// `SecurityError` on failed remote identity validation or wire
418    /// encode of the request token.
419    #[cfg(feature = "std")]
420    pub fn begin_handshake_with(
421        &mut self,
422        remote_prefix: GuidPrefix,
423        remote_guid: [u8; 16],
424        remote_identity_token: &[u8],
425    ) -> SecurityResult<Vec<OutboundDatagram>> {
426        let (auth, local_identity) = match (self.auth.clone(), self.local_identity) {
427            (Some(a), Some(id)) => (a, id),
428            _ => return Ok(Vec::new()),
429        };
430        // Peer already known: NO second handshake — but as long as the
431        // secret is missing (handshake incomplete), re-send the last sent
432        // initiator message. The periodic SPDP beacon thereby drives the
433        // resend cadence; if the initial AUTH_REQUEST (or B's reply) is
434        // lost, the next beacon recovers it instead of stalling
435        // permanently. Without a pending message (replier, or already
436        // done): no-op.
437        if let Some(peer) = self.handshakes.get(&remote_prefix) {
438            if peer.secret.is_none() {
439                if let Some(req) = peer.last_request.clone() {
440                    return self.stateless_writer.write(&req).map_err(wire_to_security);
441                }
442            }
443            return Ok(Vec::new());
444        }
445        // SMALLER local GUID = initiator (cyclone-verified: in the c2c
446        // handshake FSM trace the smaller GUID sends the AUTH_REQUEST, the
447        // larger one replies). The inverse convention would make both sides
448        // wait with cyclone (ZeroDDS < cyclone ⇒ both repliers) → deadlock.
449        let is_initiator = self.local_guid < remote_guid;
450
451        let (remote_identity, request) = {
452            let mut plugin = lock_auth(&auth)?;
453            // Vendor quirk for the initiator request: OpenDDS requires
454            // NUL-terminated c.dsign_algo/c.kagree_algo (sizeof comparison).
455            plugin.set_algo_nul_terminate(
456                self.remote_vendors.get(&remote_prefix) == Some(&VendorId::OPENDDS),
457            );
458            let remote_identity = plugin.validate_remote_identity(
459                local_identity,
460                remote_guid,
461                remote_identity_token,
462            )?;
463            let request = if is_initiator {
464                Some(plugin.begin_handshake_request(local_identity, remote_identity)?)
465            } else {
466                None
467            };
468            (remote_identity, request)
469        };
470
471        let mut peer = PeerHandshake {
472            remote_identity,
473            remote_guid,
474            handshake: None,
475            next_sn: 1,
476            secret: None,
477            last_request: None,
478        };
479
480        let mut datagrams = Vec::new();
481        if let Some((handle, outcome)) = request {
482            peer.handshake = Some(handle);
483            if let HandshakeStepOutcome::SendMessage { token } = outcome {
484                // OMG DDS-Security §7.4.4: ALL handshake tokens (request/reply/
485                // final) travel in the message_class_id `dds.sec.auth`; only the
486                // AuthRequestMessageToken uses `dds.sec.auth_request`. The
487                // HandshakeRequest has related_message_identity = NIL
488                // (= MessageIdentity::default()). Confirmed on the wire against
489                // FastDDS/Cyclone.
490                let msg = peer.build_message(
491                    self.local_guid,
492                    class_id::AUTH,
493                    token,
494                    MessageIdentity::default(),
495                )?;
496                // Remember for the resend path (periodic beacon cadence).
497                peer.last_request = Some(msg.clone());
498                datagrams = self
499                    .stateless_writer
500                    .write(&msg)
501                    .map_err(wire_to_security)?;
502            }
503        }
504        self.handshakes.insert(remote_prefix, peer);
505        Ok(datagrams)
506    }
507
508    /// Number of remote peers with established handshake state (initiator
509    /// as well as replier). Read-only observability for the discovery
510    /// trigger.
511    #[cfg(feature = "std")]
512    #[must_use]
513    pub fn handshake_peer_count(&self) -> usize {
514        self.handshakes.len()
515    }
516
517    /// FU2 handshake driver: processes an incoming stateless auth
518    /// message (Spec §8.3.2). Dispatch by `message_class_id`:
519    ///
520    /// - `AUTH_REQUEST` (replier side) → `begin_handshake_reply`,
521    ///   sends the reply token back.
522    /// - otherwise (`AUTH` = reply at the initiator / final at the
523    ///   replier) → `process_handshake`; a `SendMessage` outcome
524    ///   (initiator: final token) is sent.
525    ///
526    /// Returns `(outbound_datagrams, Option<(remote_identity,
527    /// shared_secret)>)`. The secret tuple is `Some` as soon as the
528    /// handshake derives the SharedSecret on this side (initiator after
529    /// reply processing, replier on `Complete`). The DCPS caller passes
530    /// it on to `gate.register_remote_with_token` —
531    /// **important:** the auth lock held here is already released at
532    /// return time (the crypto `SharedSecretProvider` takes the same
533    /// mutex), otherwise deadlock.
534    ///
535    /// Without a configured auth plugin, a no-op.
536    ///
537    /// # Errors
538    /// `SecurityError` from the plugin step or wire encode of the response.
539    #[cfg(feature = "std")]
540    pub fn on_stateless_message(
541        &mut self,
542        remote_prefix: GuidPrefix,
543        msg: &ParticipantGenericMessage,
544    ) -> SecurityResult<HandshakeStepResult> {
545        let (auth, local_identity) = match (self.auth.clone(), self.local_identity) {
546            (Some(a), Some(id)) => (a, id),
547            _ => return Ok((Vec::new(), None)),
548        };
549        let local_guid = self.local_guid;
550        // AuthRequestMessageToken (`dds.sec.auth_request`, +AuthReq): the
551        // peer announces its `future_challenge` with it. As initiator we
552        // do not need it — cyclone/FastDDS only check `challenge1` against
553        // a future_challenge announced by the INITIATOR, which we
554        // (deliberately) do not send, so the check is moot. IGNORE it here,
555        // otherwise the message falls into the reply/final path and
556        // parse_reply_token reports "reply: class_id mismatch".
557        if msg.message_class_id == class_id::AUTH_REQUEST {
558            return Ok((Vec::new(), None));
559        }
560        // OMG DDS-Security §7.4.4: a HandshakeRequest is message_class
561        // `dds.sec.auth` WITH related_message_identity == NIL. Reply/final are
562        // also `dds.sec.auth`, but have related != NIL. (The separate
563        // AuthRequestMessageToken uses `dds.sec.auth_request` — it is not
564        // treated as a handshake request here.) This replaces the earlier
565        // dispatch on the message class, which was cross-vendor incorrect.
566        let is_request = msg.message_class_id == class_id::AUTH
567            && msg.related_message_identity == MessageIdentity::default();
568        let mut peer = match self.handshakes.remove(&remote_prefix) {
569            Some(p) => p,
570            None => {
571                // FU2 S4: the HandshakeRequest is SELF-CONTAINED — it carries
572                // the initiator cert within it, and `begin_handshake_reply`
573                // validates it internally (`verify_remote_der`) and ignores the
574                // SPDP-derived identity handle. The replier therefore does NOT
575                // need to have discovered the initiator beforehand via an SPDP
576                // beacon (this was the true cause of the cross-process handshake
577                // break). We create the replier state on the fly. Reply/final
578                // without state = nothing to do.
579                if !is_request {
580                    return Ok((Vec::new(), None));
581                }
582                PeerHandshake {
583                    // Placeholder: ignored by `begin_handshake_reply`; after
584                    // completion only passed to the secret-based crypto register
585                    // (the Kx key comes from HKDF(shared_secret), not from the
586                    // identity handle).
587                    remote_identity: local_identity,
588                    // Destination of the reply tokens = source GUID of the request.
589                    remote_guid: msg.message_identity.source_guid,
590                    handshake: None,
591                    next_sn: 1,
592                    secret: None,
593                    last_request: None,
594                }
595            }
596        };
597        let token = match msg.message_data.first() {
598            Some(dh) => dh.to_cdr_le(),
599            None => {
600                self.handshakes.insert(remote_prefix, peer);
601                return Ok((Vec::new(), None));
602            }
603        };
604
605        // FU2 S3: duplicate request (the initiator re-sent because our reply
606        // was lost) → re-send the cached reply, do NOT run
607        // `begin_handshake_reply` again. Otherwise the replier would generate
608        // fresh DH keys and the derived secret would diverge from the
609        // initiator (silent decryption failure).
610        if is_request && peer.handshake.is_some() {
611            let resend = peer.last_request.clone();
612            self.handshakes.insert(remote_prefix, peer);
613            return match resend {
614                Some(r) => self
615                    .stateless_writer
616                    .write(&r)
617                    .map(|d| (d, None))
618                    .map_err(wire_to_security),
619                None => Ok((Vec::new(), None)),
620            };
621        }
622
623        let outcome = {
624            let mut plugin = lock_auth(&auth)?;
625            // Vendor quirk for the replier (own c.dsign_algo; c.kagree_algo
626            // is echoed from the request anyway): OpenDDS requires the NUL form.
627            plugin.set_algo_nul_terminate(
628                self.remote_vendors.get(&remote_prefix) == Some(&VendorId::OPENDDS),
629            );
630            if is_request {
631                let (handle, outcome) =
632                    plugin.begin_handshake_reply(local_identity, peer.remote_identity, &token)?;
633                peer.handshake = Some(handle);
634                outcome
635            } else {
636                match peer.handshake {
637                    Some(handle) => plugin.process_handshake(handle, &token)?,
638                    None => {
639                        drop(plugin);
640                        self.handshakes.insert(remote_prefix, peer);
641                        return Ok((Vec::new(), None));
642                    }
643                }
644            }
645        };
646
647        let mut datagrams = Vec::new();
648        if let HandshakeStepOutcome::SendMessage { token: out_token } = outcome {
649            let response = peer.build_message(
650                local_guid,
651                class_id::AUTH,
652                out_token,
653                msg.message_identity.clone(),
654            )?;
655            // Cache the replier reply (or initiator final) → idempotent
656            // resend on a lost reply, without new DH generation.
657            peer.last_request = Some(response.clone());
658            datagrams = self
659                .stateless_writer
660                .write(&response)
661                .map_err(wire_to_security)?;
662        }
663
664        // Secret extraction: after each step, check whether the plugin can
665        // now derive the SharedSecret. The initiator obtains it after the
666        // reply (outcome is SendMessage{Final}), the replier on Complete —
667        // `shared_secret(handle)` covers both cases.
668        let mut completed = None;
669        if peer.secret.is_none() {
670            if let Some(handle) = peer.handshake {
671                let secret = lock_auth(&auth)?.shared_secret(handle).ok();
672                if let Some(secret) = secret {
673                    peer.secret = Some(secret);
674                    completed = Some((peer.remote_identity, secret));
675                }
676            }
677        }
678
679        self.handshakes.insert(remote_prefix, peer);
680        Ok((datagrams, completed))
681    }
682}
683
684/// `WireError` → `SecurityError` adapter for the handshake driver.
685#[cfg(feature = "std")]
686fn wire_to_security(_e: WireError) -> zerodds_security::error::SecurityError {
687    zerodds_security::error::SecurityError::new(
688        zerodds_security::error::SecurityErrorKind::BadArgument,
689        "stateless handshake: wire encode failed",
690    )
691}
692
693#[cfg(feature = "std")]
694impl PeerHandshake {
695    /// Wraps a handshake token into a `ParticipantGenericMessage` with a
696    /// running `message_identity` and a referenced
697    /// `related_message_identity` (NIL for the initial request).
698    fn build_message(
699        &mut self,
700        local_guid: [u8; 16],
701        message_class: &str,
702        token: Vec<u8>,
703        related: MessageIdentity,
704    ) -> SecurityResult<ParticipantGenericMessage> {
705        let sequence_number = self.next_sn;
706        self.next_sn = self.next_sn.saturating_add(1);
707        // The token IS a CDR-LE-serialized DataHolder
708        // (build_*_token = DataHolder::to_cdr_le); read it back in so that
709        // the generic-message encoding re-serializes it cleanly.
710        let holder = DataHolder::from_cdr_le(&token)?;
711        Ok(ParticipantGenericMessage {
712            message_identity: MessageIdentity {
713                source_guid: local_guid,
714                sequence_number,
715            },
716            related_message_identity: related,
717            destination_participant_key: self.remote_guid,
718            destination_endpoint_key: [0; 16],
719            source_endpoint_key: [0; 16],
720            message_class_id: message_class.into(),
721            message_data: alloc::vec![holder],
722        })
723    }
724}
725
726#[cfg(test)]
727#[allow(clippy::expect_used, clippy::unwrap_used)]
728mod tests {
729    use super::*;
730    use zerodds_rtps::participant_data::{
731        Duration as DdsDuration, ParticipantBuiltinTopicData, endpoint_flag,
732    };
733    use zerodds_rtps::wire_types::ProtocolVersion;
734    use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
735    use zerodds_security::token::DataHolder;
736
737    // Local node (A) carries the SMALLER GUID, making it the initiator
738    // under the cyclone convention (smaller GUID = initiator) — so the
739    // "A sends" role assertions stay valid.
740    fn local_prefix() -> GuidPrefix {
741        GuidPrefix::from_bytes([1; 12])
742    }
743    fn remote_prefix() -> GuidPrefix {
744        GuidPrefix::from_bytes([2; 12])
745    }
746
747    fn remote_with(flags: u32) -> DiscoveredParticipant {
748        DiscoveredParticipant {
749            sender_prefix: remote_prefix(),
750            sender_vendor: VendorId::ZERODDS,
751            data: ParticipantBuiltinTopicData {
752                guid: Guid::new(remote_prefix(), EntityId::PARTICIPANT),
753                protocol_version: ProtocolVersion::V2_5,
754                vendor_id: VendorId::ZERODDS,
755                default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7411)),
756                default_multicast_locator: None,
757                metatraffic_unicast_locator: None,
758                metatraffic_multicast_locator: None,
759                domain_id: None,
760                builtin_endpoint_set: flags,
761                lease_duration: DdsDuration::from_secs(30),
762                user_data: alloc::vec::Vec::new(),
763                properties: Default::default(),
764                identity_token: None,
765                permissions_token: None,
766                identity_status_token: None,
767                sig_algo_info: None,
768                kx_algo_info: None,
769                sym_cipher_algo_info: None,
770                participant_security_info: None,
771            },
772        }
773    }
774
775    fn sample_stateless_msg() -> ParticipantGenericMessage {
776        ParticipantGenericMessage {
777            message_identity: MessageIdentity {
778                source_guid: [0xAA; 16],
779                sequence_number: 1,
780            },
781            related_message_identity: MessageIdentity::default(),
782            destination_participant_key: [0xBB; 16],
783            destination_endpoint_key: [0; 16],
784            source_endpoint_key: [0xCC; 16],
785            message_class_id: class_id::AUTH_REQUEST.into(),
786            message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
787        }
788    }
789
790    #[test]
791    fn new_stack_has_zero_proxies_everywhere() {
792        let s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
793        assert_eq!(s.stateless_writer.reader_proxy_count(), 0);
794        assert_eq!(s.stateless_reader.writer_proxy_count(), 0);
795        assert_eq!(s.volatile_writer.reader_proxy_count(), 0);
796        assert_eq!(s.volatile_reader.writer_proxy_count(), 0);
797        assert_eq!(s.local_prefix(), local_prefix());
798    }
799
800    #[test]
801    fn handle_remote_endpoints_with_all_bits_wires_all_four() {
802        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
803        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
804            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
805            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
806            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
807        s.handle_remote_endpoints(&remote_with(flags));
808        assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
809        assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
810        assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
811        assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
812    }
813
814    #[test]
815    fn handle_remote_endpoints_with_only_stateless_bits_skips_volatile() {
816        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
817        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
818            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
819        s.handle_remote_endpoints(&remote_with(flags));
820        assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
821        assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
822        assert_eq!(s.volatile_writer.reader_proxy_count(), 0);
823        assert_eq!(s.volatile_reader.writer_proxy_count(), 0);
824    }
825
826    #[test]
827    fn handle_remote_endpoints_with_no_security_bits_is_noop() {
828        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
829        let flags = endpoint_flag::ALL_STANDARD;
830        s.handle_remote_endpoints(&remote_with(flags));
831        assert_eq!(s.stateless_writer.reader_proxy_count(), 0);
832        assert_eq!(s.volatile_writer.reader_proxy_count(), 0);
833    }
834
835    #[test]
836    fn self_discovery_is_ignored() {
837        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
838        let mut peer = remote_with(endpoint_flag::ALL_SECURE);
839        peer.sender_prefix = local_prefix();
840        s.handle_remote_endpoints(&peer);
841        assert_eq!(s.stateless_writer.reader_proxy_count(), 0);
842    }
843
844    #[test]
845    fn handle_remote_endpoints_is_idempotent_on_repeat_announcement() {
846        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
847        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
848            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
849        s.handle_remote_endpoints(&remote_with(flags));
850        s.handle_remote_endpoints(&remote_with(flags));
851        assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
852    }
853
854    #[test]
855    fn on_participant_lost_clears_proxies() {
856        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
857        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
858            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
859            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
860            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
861        s.handle_remote_endpoints(&remote_with(flags));
862        let (sl, vol) = s.on_participant_lost(remote_prefix());
863        assert_eq!(sl, 1);
864        assert_eq!(vol, 1);
865        assert_eq!(s.stateless_writer.reader_proxy_count(), 0);
866        assert_eq!(s.volatile_writer.reader_proxy_count(), 0);
867    }
868
869    #[test]
870    fn poll_on_empty_stack_returns_no_datagrams() {
871        let mut s = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
872        let dgs = s.poll(Duration::from_secs(1)).unwrap();
873        assert!(dgs.is_empty());
874    }
875
876    #[test]
877    fn end_to_end_stateless_message_loopback_between_stacks() {
878        let mut a = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
879        let mut b = SecurityBuiltinStack::new(remote_prefix(), VendorId::ZERODDS);
880        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
881            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
882        // A discovers B
883        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
884        // B discovers A
885        b.handle_remote_endpoints(&remote_with_prefix(local_prefix(), flags));
886
887        let msg = sample_stateless_msg();
888        let dgs = a.stateless_writer.write(&msg).unwrap();
889        assert_eq!(dgs.len(), 1);
890        let received = b.stateless_reader.handle_datagram(&dgs[0].bytes).unwrap();
891        assert_eq!(received.len(), 1);
892        assert_eq!(received[0], msg);
893    }
894
895    fn remote_with_prefix(prefix: GuidPrefix, flags: u32) -> DiscoveredParticipant {
896        let mut peer = remote_with(flags);
897        peer.sender_prefix = prefix;
898        peer.data.guid = Guid::new(prefix, EntityId::PARTICIPANT);
899        peer
900    }
901
902    // ---------------------------------------------------------------
903    // FU2/Gap-3: handshake driver — two stacks drive a full PKI 3-round
904    // handshake over a stateless loopback and BOTH derive the same
905    // SharedSecret.
906    // ---------------------------------------------------------------
907    #[cfg(feature = "std")]
908    #[allow(clippy::type_complexity)]
909    fn mint_ca_and_two_leafs() -> (Vec<u8>, (Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
910        use alloc::string::String;
911        use rcgen::{CertificateParams, KeyPair};
912        let mut ca_params = CertificateParams::new(std::vec![String::from("Common CA")]).unwrap();
913        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
914        let ca_key = KeyPair::generate().unwrap();
915        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
916        let ca_pem = ca_cert.pem().into_bytes();
917
918        let mint_leaf = |name: &str| -> (Vec<u8>, Vec<u8>) {
919            let mut params = CertificateParams::new(std::vec![String::from(name)]).unwrap();
920            params.is_ca = rcgen::IsCa::NoCa;
921            let key = KeyPair::generate().unwrap();
922            let cert = params.signed_by(&key, &ca_cert, &ca_key).unwrap();
923            (cert.pem().into_bytes(), key.serialize_pem().into_bytes())
924        };
925        (ca_pem, mint_leaf("alice"), mint_leaf("bob"))
926    }
927
928    #[cfg(feature = "std")]
929    fn cert_der_from_pem(pem: &[u8]) -> Vec<u8> {
930        use rustls_pki_types::CertificateDer;
931        use rustls_pki_types::pem::PemObject;
932        CertificateDer::pem_slice_iter(pem)
933            .next()
934            .unwrap()
935            .unwrap()
936            .as_ref()
937            .to_vec()
938    }
939
940    #[cfg(feature = "std")]
941    #[test]
942    fn full_pki_handshake_between_two_stacks_yields_shared_secret() {
943        use alloc::sync::Arc;
944        use std::sync::Mutex;
945        use zerodds_security::authentication::AuthenticationPlugin;
946        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
947
948        let (ca_pem, (a_cert, a_key), (b_cert, b_key)) = mint_ca_and_two_leafs();
949
950        let a_guid = Guid::new(local_prefix(), EntityId::PARTICIPANT).to_bytes();
951        let b_guid = Guid::new(remote_prefix(), EntityId::PARTICIPANT).to_bytes();
952
953        // Validate local identities (own plugin per stack).
954        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
955        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
956        let a_local = a_pki
957            .lock()
958            .unwrap()
959            .validate_with_config(
960                IdentityConfig {
961                    identity_cert_pem: a_cert.clone(),
962                    identity_ca_pem: ca_pem.clone(),
963                    identity_key_pem: Some(a_key),
964                },
965                a_guid,
966            )
967            .unwrap();
968        let b_local = b_pki
969            .lock()
970            .unwrap()
971            .validate_with_config(
972                IdentityConfig {
973                    identity_cert_pem: b_cert.clone(),
974                    identity_ca_pem: ca_pem,
975                    identity_key_pem: Some(b_key),
976                },
977                b_guid,
978            )
979            .unwrap();
980
981        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
982        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
983        let mut a = SecurityBuiltinStack::with_auth(
984            local_prefix(),
985            VendorId::ZERODDS,
986            a_auth,
987            a_local,
988            a_guid,
989        );
990        let mut b = SecurityBuiltinStack::with_auth(
991            remote_prefix(),
992            VendorId::ZERODDS,
993            b_auth,
994            b_local,
995            b_guid,
996        );
997
998        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
999            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
1000        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
1001        b.handle_remote_endpoints(&remote_with_prefix(local_prefix(), flags));
1002
1003        // Both discover each other and call begin_handshake_with. Only
1004        // the initiator (smaller prefix → A, cyclone convention) sends.
1005        let mut in_flight = a
1006            .begin_handshake_with(remote_prefix(), b_guid, &cert_der_from_pem(&b_cert))
1007            .unwrap();
1008        let from_b = b
1009            .begin_handshake_with(local_prefix(), a_guid, &cert_der_from_pem(&a_cert))
1010            .unwrap();
1011        assert!(
1012            from_b.is_empty(),
1013            "B is the replier (larger prefix), must not initiate"
1014        );
1015        assert_eq!(in_flight.len(), 1, "A sends exactly one AUTH_REQUEST");
1016
1017        // Ping-pong: deliver the datagram to the other side, whose response
1018        // becomes the next in_flight. Loop until both secrets are present.
1019        let mut a_secret = None;
1020        let mut b_secret = None;
1021        let mut deliver_to_b = true; // the first datagram goes from A to B
1022        for _ in 0..6 {
1023            if in_flight.is_empty() {
1024                break;
1025            }
1026            let datagram = in_flight.remove(0);
1027            let (target, target_prefix) = if deliver_to_b {
1028                (&mut b, local_prefix())
1029            } else {
1030                (&mut a, remote_prefix())
1031            };
1032            let msgs = target
1033                .stateless_reader
1034                .handle_datagram(&datagram.bytes)
1035                .unwrap();
1036            assert_eq!(msgs.len(), 1, "ein generic-message pro Datagram");
1037            let (out, completed) = target
1038                .on_stateless_message(target_prefix, &msgs[0])
1039                .unwrap();
1040            if let Some((_id, secret)) = completed {
1041                if deliver_to_b {
1042                    b_secret = Some(secret);
1043                } else {
1044                    a_secret = Some(secret);
1045                }
1046            }
1047            in_flight = out;
1048            deliver_to_b = !deliver_to_b;
1049        }
1050
1051        let a_secret = a_secret.expect("A must derive a secret");
1052        let b_secret = b_secret.expect("B must derive a secret");
1053        let a_bytes = a_pki
1054            .lock()
1055            .unwrap()
1056            .secret_bytes(a_secret)
1057            .unwrap()
1058            .to_vec();
1059        let b_bytes = b_pki
1060            .lock()
1061            .unwrap()
1062            .secret_bytes(b_secret)
1063            .unwrap()
1064            .to_vec();
1065        assert_eq!(a_bytes.len(), 32);
1066        assert_eq!(a_bytes, b_bytes, "both stacks derive the same secret");
1067    }
1068
1069    #[cfg(feature = "std")]
1070    #[test]
1071    fn smaller_local_guid_initiates_handshake_cyclone_compatible() {
1072        // cyclone-verified (c2c handshake FSM trace): the SMALLER GUID
1073        // sends the AUTH_REQUEST (initiator), the larger one replies. ZeroDDS
1074        // MUST choose the same direction — otherwise both sides wait with
1075        // cyclone (ZeroDDS < cyclone ⇒ both repliers) and the handshake stalls.
1076        use alloc::sync::Arc;
1077        use std::sync::Mutex;
1078        use zerodds_security::authentication::AuthenticationPlugin;
1079        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
1080
1081        let (ca_pem, (big_cert, big_key), (small_cert, small_key)) = mint_ca_and_two_leafs();
1082        let big_prefix = GuidPrefix::from_bytes([0x09; 12]);
1083        let small_prefix = GuidPrefix::from_bytes([0x01; 12]);
1084        let big_guid = Guid::new(big_prefix, EntityId::PARTICIPANT).to_bytes();
1085        let small_guid = Guid::new(small_prefix, EntityId::PARTICIPANT).to_bytes();
1086        assert!(big_guid > small_guid, "test setup: big must be > small");
1087
1088        let mk = |cert: Vec<u8>, key: Vec<u8>, prefix: GuidPrefix, guid: [u8; 16]| {
1089            let pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1090            let local = pki
1091                .lock()
1092                .unwrap()
1093                .validate_with_config(
1094                    IdentityConfig {
1095                        identity_cert_pem: cert,
1096                        identity_ca_pem: ca_pem.clone(),
1097                        identity_key_pem: Some(key),
1098                    },
1099                    guid,
1100                )
1101                .unwrap();
1102            let auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki.clone();
1103            SecurityBuiltinStack::with_auth(prefix, VendorId::ZERODDS, auth, local, guid)
1104        };
1105
1106        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
1107            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
1108
1109        // Small node: local < remote ⇒ MUST initiate.
1110        let mut small = mk(small_cert.clone(), small_key, small_prefix, small_guid);
1111        small.handle_remote_endpoints(&remote_with_prefix(big_prefix, flags));
1112        let from_small = small
1113            .begin_handshake_with(big_prefix, big_guid, &cert_der_from_pem(&big_cert))
1114            .unwrap();
1115        assert_eq!(
1116            from_small.len(),
1117            1,
1118            "smaller local GUID MUST send AUTH_REQUEST (initiator, cyclone convention)"
1119        );
1120
1121        // Large node: local > remote ⇒ MUST wait (replier).
1122        let mut big = mk(big_cert, big_key, big_prefix, big_guid);
1123        big.handle_remote_endpoints(&remote_with_prefix(small_prefix, flags));
1124        let from_big = big
1125            .begin_handshake_with(small_prefix, small_guid, &cert_der_from_pem(&small_cert))
1126            .unwrap();
1127        assert!(
1128            from_big.is_empty(),
1129            "larger local GUID MUST wait (replier), must not initiate"
1130        );
1131    }
1132
1133    #[test]
1134    fn initiator_resends_auth_request_on_repeated_begin_while_incomplete() {
1135        // FU2 S3: stateless auth is best-effort. If the initial
1136        // AUTH_REQUEST (or B's reply) is lost, the next SPDP-beacon-driven
1137        // `begin_handshake_with` call must re-send the AUTH_REQUEST as long
1138        // as the secret is missing — otherwise the handshake stalls
1139        // permanently (startup race).
1140        use alloc::sync::Arc;
1141        use std::sync::Mutex;
1142        use zerodds_security::authentication::AuthenticationPlugin;
1143        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
1144
1145        let (ca_pem, (a_cert, a_key), (b_cert, _b_key)) = mint_ca_and_two_leafs();
1146        let a_guid = Guid::new(local_prefix(), EntityId::PARTICIPANT).to_bytes();
1147        let b_guid = Guid::new(remote_prefix(), EntityId::PARTICIPANT).to_bytes();
1148
1149        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1150        let a_local = a_pki
1151            .lock()
1152            .unwrap()
1153            .validate_with_config(
1154                IdentityConfig {
1155                    identity_cert_pem: a_cert.clone(),
1156                    identity_ca_pem: ca_pem,
1157                    identity_key_pem: Some(a_key),
1158                },
1159                a_guid,
1160            )
1161            .unwrap();
1162        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
1163        let mut a = SecurityBuiltinStack::with_auth(
1164            local_prefix(),
1165            VendorId::ZERODDS,
1166            a_auth,
1167            a_local,
1168            a_guid,
1169        );
1170
1171        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
1172            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
1173        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
1174
1175        let b_token = cert_der_from_pem(&b_cert);
1176        let first = a
1177            .begin_handshake_with(remote_prefix(), b_guid, &b_token)
1178            .unwrap();
1179        assert_eq!(first.len(), 1, "first beacon: AUTH_REQUEST sent");
1180
1181        // Second beacon, handshake still incomplete (no secret) →
1182        // RESEND instead of idempotent-empty.
1183        let resend = a
1184            .begin_handshake_with(remote_prefix(), b_guid, &b_token)
1185            .unwrap();
1186        assert_eq!(
1187            resend.len(),
1188            1,
1189            "second beacon: AUTH_REQUEST must be sent again (resend, not idempotent-empty)"
1190        );
1191    }
1192
1193    #[test]
1194    fn lost_reply_recovered_by_resend_yields_matching_secret() {
1195        // FU2 S3: B's reply is lost → A re-sends AUTH_REQUEST → B MUST
1196        // re-send the CACHED reply (not regenerate it), otherwise the DH
1197        // secrets diverge. Proof: after loss+resend both sides derive the
1198        // SAME secret.
1199        use alloc::sync::Arc;
1200        use std::sync::Mutex;
1201        use zerodds_security::authentication::AuthenticationPlugin;
1202        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
1203
1204        let (ca_pem, (a_cert, a_key), (b_cert, b_key)) = mint_ca_and_two_leafs();
1205        let a_guid = Guid::new(local_prefix(), EntityId::PARTICIPANT).to_bytes();
1206        let b_guid = Guid::new(remote_prefix(), EntityId::PARTICIPANT).to_bytes();
1207        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1208        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1209        let a_local = a_pki
1210            .lock()
1211            .unwrap()
1212            .validate_with_config(
1213                IdentityConfig {
1214                    identity_cert_pem: a_cert.clone(),
1215                    identity_ca_pem: ca_pem.clone(),
1216                    identity_key_pem: Some(a_key),
1217                },
1218                a_guid,
1219            )
1220            .unwrap();
1221        let b_local = b_pki
1222            .lock()
1223            .unwrap()
1224            .validate_with_config(
1225                IdentityConfig {
1226                    identity_cert_pem: b_cert.clone(),
1227                    identity_ca_pem: ca_pem,
1228                    identity_key_pem: Some(b_key),
1229                },
1230                b_guid,
1231            )
1232            .unwrap();
1233        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
1234        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
1235        let mut a = SecurityBuiltinStack::with_auth(
1236            local_prefix(),
1237            VendorId::ZERODDS,
1238            a_auth,
1239            a_local,
1240            a_guid,
1241        );
1242        let mut b = SecurityBuiltinStack::with_auth(
1243            remote_prefix(),
1244            VendorId::ZERODDS,
1245            b_auth,
1246            b_local,
1247            b_guid,
1248        );
1249        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
1250            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
1251        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
1252        b.handle_remote_endpoints(&remote_with_prefix(local_prefix(), flags));
1253
1254        let mut a_secret = None;
1255        let mut b_secret = None;
1256
1257        // Both discover each other; B (replier) creates its peer state for A
1258        // (sends nothing itself).
1259        let from_b = b
1260            .begin_handshake_with(local_prefix(), a_guid, &cert_der_from_pem(&a_cert))
1261            .unwrap();
1262        assert!(from_b.is_empty(), "B is the replier, does not initiate");
1263
1264        // 1. A → AUTH_REQUEST. 2. B processes → reply LOST (discarded).
1265        let req = a
1266            .begin_handshake_with(remote_prefix(), b_guid, &cert_der_from_pem(&b_cert))
1267            .unwrap();
1268        let m = b.stateless_reader.handle_datagram(&req[0].bytes).unwrap();
1269        let (_lost, b_c0) = b.on_stateless_message(local_prefix(), &m[0]).unwrap();
1270        b_secret = b_secret.or(b_c0.map(|x| x.1));
1271
1272        // 3. A's next beacon → resend of the same AUTH_REQUEST.
1273        let req2 = a
1274            .begin_handshake_with(remote_prefix(), b_guid, &cert_der_from_pem(&b_cert))
1275            .unwrap();
1276        assert_eq!(req2.len(), 1, "AUTH_REQUEST must be resent");
1277
1278        // 4. B sees the duplicate → re-sends the CACHED reply. Ping-pong from here.
1279        let m2 = b.stateless_reader.handle_datagram(&req2[0].bytes).unwrap();
1280        let (reply, b_c1) = b.on_stateless_message(local_prefix(), &m2[0]).unwrap();
1281        b_secret = b_secret.or(b_c1.map(|x| x.1));
1282        assert_eq!(reply.len(), 1, "B resends the cached reply");
1283
1284        let mut in_flight = reply;
1285        let mut deliver_to_b = false; // the reply goes to A first
1286        for _ in 0..6 {
1287            if in_flight.is_empty() {
1288                break;
1289            }
1290            let dg = in_flight.remove(0);
1291            let (target, target_prefix) = if deliver_to_b {
1292                (&mut b, local_prefix())
1293            } else {
1294                (&mut a, remote_prefix())
1295            };
1296            let msgs = target.stateless_reader.handle_datagram(&dg.bytes).unwrap();
1297            let (out, completed) = target
1298                .on_stateless_message(target_prefix, &msgs[0])
1299                .unwrap();
1300            if let Some((_id, secret)) = completed {
1301                if deliver_to_b {
1302                    b_secret = Some(secret);
1303                } else {
1304                    a_secret = Some(secret);
1305                }
1306            }
1307            in_flight = out;
1308            deliver_to_b = !deliver_to_b;
1309        }
1310
1311        let a_secret = a_secret.expect("A must derive a secret");
1312        let b_secret = b_secret.expect("B must derive a secret");
1313        let a_bytes = a_pki
1314            .lock()
1315            .unwrap()
1316            .secret_bytes(a_secret)
1317            .unwrap()
1318            .to_vec();
1319        let b_bytes = b_pki
1320            .lock()
1321            .unwrap()
1322            .secret_bytes(b_secret)
1323            .unwrap()
1324            .to_vec();
1325        assert_eq!(
1326            a_bytes, b_bytes,
1327            "secret after reply-loss+resend must match (B must NOT have regenerated)"
1328        );
1329    }
1330
1331    #[test]
1332    fn replier_handles_auth_request_without_prior_discovery() {
1333        // FU2 S4 (true cause of the cross-process break): the replier (B)
1334        // receives A's AUTH_REQUEST WITHOUT having called
1335        // `begin_handshake_with` beforehand (= without having processed A's
1336        // SPDP beacon). Since the request is self-contained, B MUST still
1337        // process it, reply, and ultimately derive the same secret as A — no
1338        // timer, no resend crutch needed.
1339        use alloc::sync::Arc;
1340        use std::sync::Mutex;
1341        use zerodds_security::authentication::AuthenticationPlugin;
1342        use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
1343
1344        let (ca_pem, (a_cert, a_key), (b_cert, b_key)) = mint_ca_and_two_leafs();
1345        let a_guid = Guid::new(local_prefix(), EntityId::PARTICIPANT).to_bytes();
1346        let b_guid = Guid::new(remote_prefix(), EntityId::PARTICIPANT).to_bytes();
1347        let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1348        let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
1349        let a_local = a_pki
1350            .lock()
1351            .unwrap()
1352            .validate_with_config(
1353                IdentityConfig {
1354                    identity_cert_pem: a_cert.clone(),
1355                    identity_ca_pem: ca_pem.clone(),
1356                    identity_key_pem: Some(a_key),
1357                },
1358                a_guid,
1359            )
1360            .unwrap();
1361        let b_local = b_pki
1362            .lock()
1363            .unwrap()
1364            .validate_with_config(
1365                IdentityConfig {
1366                    identity_cert_pem: b_cert.clone(),
1367                    identity_ca_pem: ca_pem,
1368                    identity_key_pem: Some(b_key),
1369                },
1370                b_guid,
1371            )
1372            .unwrap();
1373        let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
1374        let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
1375        let mut a = SecurityBuiltinStack::with_auth(
1376            local_prefix(),
1377            VendorId::ZERODDS,
1378            a_auth,
1379            a_local,
1380            a_guid,
1381        );
1382        let mut b = SecurityBuiltinStack::with_auth(
1383            remote_prefix(),
1384            VendorId::ZERODDS,
1385            b_auth,
1386            b_local,
1387            b_guid,
1388        );
1389        let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
1390            | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
1391        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
1392        b.handle_remote_endpoints(&remote_with_prefix(local_prefix(), flags));
1393
1394        // A initiates. B DELIBERATELY does NOT call `begin_handshake_with` —
1395        // so B has NOT discovered A via SPDP (= the cross-process race case).
1396        let mut in_flight = a
1397            .begin_handshake_with(remote_prefix(), b_guid, &cert_der_from_pem(&b_cert))
1398            .unwrap();
1399        assert_eq!(in_flight.len(), 1, "A sends AUTH_REQUEST");
1400
1401        let mut a_secret = None;
1402        let mut b_secret = None;
1403        let mut deliver_to_b = true;
1404        for _ in 0..6 {
1405            if in_flight.is_empty() {
1406                break;
1407            }
1408            let dg = in_flight.remove(0);
1409            let (target, target_prefix) = if deliver_to_b {
1410                (&mut b, local_prefix())
1411            } else {
1412                (&mut a, remote_prefix())
1413            };
1414            let msgs = target.stateless_reader.handle_datagram(&dg.bytes).unwrap();
1415            let (out, completed) = target
1416                .on_stateless_message(target_prefix, &msgs[0])
1417                .unwrap();
1418            if let Some((_id, secret)) = completed {
1419                if deliver_to_b {
1420                    b_secret = Some(secret);
1421                } else {
1422                    a_secret = Some(secret);
1423                }
1424            }
1425            in_flight = out;
1426            deliver_to_b = !deliver_to_b;
1427        }
1428
1429        let a_secret = a_secret.expect("A derives a secret");
1430        let b_secret = b_secret.expect("B (without prior discovery) derives a secret");
1431        let a_bytes = a_pki
1432            .lock()
1433            .unwrap()
1434            .secret_bytes(a_secret)
1435            .unwrap()
1436            .to_vec();
1437        let b_bytes = b_pki
1438            .lock()
1439            .unwrap()
1440            .secret_bytes(b_secret)
1441            .unwrap()
1442            .to_vec();
1443        assert_eq!(
1444            a_bytes, b_bytes,
1445            "secret must match even though B processed the request without prior discovery"
1446        );
1447    }
1448
1449    #[test]
1450    fn end_to_end_volatile_secure_handshake_via_reliable_loop() {
1451        // A sends a crypto-token message over the VolatileSecure topic to
1452        // B. We simulate the reliable hop manually:
1453        // 1. A.write produces DATA + (an initial pre-emptive HEARTBEAT follows in the tick)
1454        // 2. B.handle_data decodes the message
1455        // 3. B.tick_outbound emits an ACKNACK
1456        // 4. A.handle_acknack accepts it
1457        let mut a = SecurityBuiltinStack::new(local_prefix(), VendorId::ZERODDS);
1458        let mut b = SecurityBuiltinStack::new(remote_prefix(), VendorId::ZERODDS);
1459        let flags = endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1460            | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
1461        a.handle_remote_endpoints(&remote_with_prefix(remote_prefix(), flags));
1462        b.handle_remote_endpoints(&remote_with_prefix(local_prefix(), flags));
1463
1464        let mut msg = sample_stateless_msg();
1465        msg.message_class_id = class_id::PARTICIPANT_CRYPTO_TOKENS.into();
1466
1467        let dgs = a.volatile_writer.write(&msg).unwrap();
1468        assert_eq!(dgs.len(), 1, "ein Datagram pro Reader-Proxy");
1469        // Wire-decode + dispatch into B's volatile reader
1470        let parsed = zerodds_rtps::datagram::decode_datagram(&dgs[0].bytes).unwrap();
1471        let mut received_msgs = Vec::new();
1472        for sub in parsed.submessages {
1473            if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
1474                if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
1475                    received_msgs.extend(
1476                        b.volatile_reader
1477                            .handle_data(parsed.header.guid_prefix, &d)
1478                            .unwrap(),
1479                    );
1480                }
1481            }
1482        }
1483        assert_eq!(received_msgs.len(), 1);
1484        assert_eq!(received_msgs[0], msg);
1485
1486        // ACKNACK flows back, A must accept handle_acknack
1487        let outbound = b
1488            .volatile_reader
1489            .tick_outbound(Duration::from_millis(500))
1490            .unwrap();
1491        // B knows A as a writer proxy → ACKNACK datagrams should exist
1492        assert!(
1493            !outbound.is_empty(),
1494            "reader should send an initial ACKNACK"
1495        );
1496    }
1497}