Skip to main content

zerodds_security_pki/
plugin.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `AuthenticationPlugin` impl based on X.509 / rustls-webpki.
5//!
6//! Spec: OMG DDS-Security 1.2 §10.3.2.6-8 + §10.3.4. Tokens are encoded via
7//! the `DataHolder` wire format from [`crate::handshake_token`].
8//!
9//! zerodds-lint: allow no_dyn_in_safe
10//! (`webpki::EndEntityCert::verify_signature` takes
11//! `&dyn SignatureVerificationAlgorithm` — that is a 3rd-party API,
12//! not a ZeroDDS construction. We cannot convert it to concrete
13//! generics without forking webpki.)
14
15use alloc::collections::{BTreeMap, BTreeSet};
16use alloc::vec::Vec;
17use core::sync::atomic::{AtomicU64, Ordering};
18
19use crate::backend::digest;
20use crate::backend::rand::{SecureRandom, SystemRandom};
21use crate::backend::signature;
22use rustls_pki_types::CertificateDer;
23use zerodds_security::authentication::{
24    AuthenticationPlugin, HandshakeHandle, HandshakeStepOutcome, IdentityHandle,
25    SharedSecretHandle, SharedSecretProvider,
26};
27use zerodds_security::error::{SecurityError, SecurityErrorKind, SecurityResult};
28use zerodds_security::properties::PropertyList;
29use zerodds_security_keyexchange::{KeyExchange, KxSuite};
30
31use crate::handshake_token::{
32    self as ht, FinalBuildInput, ReplyBuildInput, RequestBuildInput, ct_eq,
33};
34use crate::identity::{CertKeyAlgo, IdentityConfig, ParsedIdentity, PkiError};
35
36/// Property keys (spec §8.3.2.7 / implementation-defined). We follow
37/// the FastDDS convention so that users can reuse existing XML
38/// configs.
39mod keys {
40    /// PEM-encoded identity certificate (directly in the property value,
41    /// **not** as a file path — so the plugin caller can decide
42    /// whether to load from the filesystem or from a secret manager).
43    pub const IDENTITY_CERT: &str = "dds.sec.auth.identity_certificate";
44    /// PEM-encoded CA bundle.
45    pub const IDENTITY_CA: &str = "dds.sec.auth.identity_ca";
46    /// PEM-encoded PKCS8 private key (matching the identity cert).
47    pub const IDENTITY_KEY: &str = "dds.sec.auth.private_key";
48}
49
50/// Maximum storage for "recently seen" initiator challenges
51/// (replay-detection cache per replier). 1024 entries × 32 bytes = 32 KiB.
52const REPLAY_CACHE_CAP: usize = 1024;
53
54/// PKI/X.509-based `AuthenticationPlugin`. Verifies identity
55/// certs against a given trust anchor and runs a
56/// 3-round PKI-DH handshake (spec §10.3.2.6-8 Tab.56/57/58) with the peer.
57///
58/// Wire (C3.1): three `DataHolder` tokens
59/// `DDS:Auth:PKI-DH:1.2+AuthReq` / `+AuthReply` / `+AuthFinal`. Both
60/// sides echo `cert_der` + `dh*` + `challenge*` + hash bindings.
61/// The replier signs (kagree || ch1 || dh1 || ch2 || dh2) with the
62/// identity private key; the initiator signs (kagree || ch2 || dh2 ||
63/// ch1 || dh1).
64pub struct PkiAuthenticationPlugin {
65    next_handle: AtomicU64,
66    identities: BTreeMap<IdentityHandle, ParsedIdentity>,
67    /// Initiator side: handshakes not yet completed.
68    pending_initiator: BTreeMap<HandshakeHandle, InitiatorState>,
69    /// Replier side: between reply-send and final-receive.
70    pending_replier: BTreeMap<HandshakeHandle, ReplierState>,
71    /// Completed handshakes → SharedSecret handle.
72    handshake_to_secret: BTreeMap<HandshakeHandle, SharedSecretHandle>,
73    /// Materialized SharedSecrets (32 bytes SHA256(raw_dh)).
74    secrets: BTreeMap<SharedSecretHandle, Vec<u8>>,
75    /// Per SharedSecret the `(challenge1, challenge2)` of the handshake
76    /// (initiator/replier), for the VolatileSecure key derivation §9.5.3.5.
77    secret_challenges: BTreeMap<SharedSecretHandle, ([u8; 32], [u8; 32])>,
78    /// Per local identity handle: the set of all initiator challenges
79    /// already seen (replay detection). Capped.
80    replay_cache: BTreeMap<IdentityHandle, BTreeSet<[u8; 32]>>,
81    /// FIFO order of the replay-cache entries for cap eviction.
82    replay_order: BTreeMap<IdentityHandle, Vec<[u8; 32]>>,
83    /// Preferred key agreement as initiator. **Default = `EcdhP256`**
84    /// (OMG spec `ECDHE+P-256+SHA-256`, cross-vendor-readable). X25519 is only
85    /// allowed as an explicitly set vendor extension, NEVER the default. The replier
86    /// always follows the `c.kagree_algo` announced by the initiator.
87    preferred_kx_suite: KxSuite,
88    /// Cross-vendor quirk (transient, per-peer): if `true`, the
89    /// next `c.dsign_algo`/`c.kagree_algo` are emitted + hashed WITH a trailing `\0`.
90    /// OpenDDS compares them via `sizeof` (incl. NUL); FastDDS
91    /// (#3803) needs them WITHOUT. The discovery layer sets this via
92    /// [`AuthenticationPlugin::set_algo_nul_terminate`] based on the peer
93    /// VendorId BEFORE each `begin_handshake_request`/`begin_handshake_reply`.
94    algo_nul: bool,
95    /// Local CMS-signed permissions document (`.p7s` bytes) for the
96    /// `c.perm` property in the handshake (spec §9.3.2.5.1). Empty = no
97    /// AccessControl permissions; a foreign vendor with active AccessControl
98    /// (governance) then rejects the handshake. Set by the SecurityProfile.
99    local_permissions: Vec<u8>,
100    /// Local `ParticipantBuiltinTopicData` as PL_CDR bytes — sent along in the
101    /// handshake as `c.pdata` (spec §9.3.2.5.2). The replier
102    /// (e.g. cyclone) deserializes c.pdata as a ParameterList; empty leads
103    /// cross-vendor to "Deserialize parameter header failed".
104    local_pdata: Vec<u8>,
105}
106
107struct InitiatorState {
108    /// Local identity handle (for cert/key lookup).
109    local: IdentityHandle,
110    /// Ephemeral DH keypair (consumed at the REPLY).
111    kx: Option<KeyExchange>,
112    /// Bytes of the local DH public.
113    dh1: Vec<u8>,
114    /// Locally generated challenge.
115    challenge1: [u8; 32],
116    /// Hash binding of the initiator tuple.
117    hash_c1: [u8; 32],
118    /// Permissions document (echo). Consumed by the AccessControl plugin in the
119    /// permissions-bind step; held here only for symmetry
120    /// with the replier state.
121    #[allow(dead_code)]
122    permissions: Vec<u8>,
123    /// Pdata (echo).
124    #[allow(dead_code)]
125    pdata: Vec<u8>,
126    /// Our own `c.kagree_algo` (what we sent in the REQUEST).
127    kagree_algo: alloc::string::String,
128    /// Our own `c.dsign_algo`. Echoed by the replier and checked for
129    /// plausibility on reply decode (no match comparison
130    /// here, because the initiator sends the replier algo).
131    #[allow(dead_code)]
132    dsign_algo: alloc::string::String,
133}
134
135struct ReplierState {
136    /// Local identity handle.
137    local: IdentityHandle,
138    /// Initiator challenge.
139    challenge1: [u8; 32],
140    /// Replier challenge.
141    challenge2: [u8; 32],
142    /// Initiator DH (echo).
143    dh1: Vec<u8>,
144    /// Replier DH.
145    dh2: Vec<u8>,
146    /// hash_c1 (echo).
147    hash_c1: [u8; 32],
148    /// hash_c2 (replier tuple).
149    hash_c2: [u8; 32],
150    /// SharedSecret already derived.
151    secret_handle: SharedSecretHandle,
152    /// Initiator cert DER (for the final-signature check).
153    initiator_cert_der: Vec<u8>,
154    /// Detected initiator cert algo.
155    initiator_key_algo: CertKeyAlgo,
156}
157
158impl Default for PkiAuthenticationPlugin {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl PkiAuthenticationPlugin {
165    /// Constructor.
166    #[must_use]
167    pub fn new() -> Self {
168        Self {
169            next_handle: AtomicU64::new(0),
170            identities: BTreeMap::new(),
171            pending_initiator: BTreeMap::new(),
172            pending_replier: BTreeMap::new(),
173            handshake_to_secret: BTreeMap::new(),
174            secrets: BTreeMap::new(),
175            secret_challenges: BTreeMap::new(),
176            replay_cache: BTreeMap::new(),
177            replay_order: BTreeMap::new(),
178            // Spec default: cross-vendor-readable ECDHE+P-256+SHA-256.
179            preferred_kx_suite: KxSuite::EcdhP256,
180            algo_nul: false,
181            local_permissions: Vec::new(),
182            local_pdata: Vec::new(),
183        }
184    }
185
186    /// Sets the local CMS-signed permissions document (`.p7s` bytes) that
187    /// is sent along as `c.perm` in the HandshakeRequest/Reply. Required for
188    /// cross-vendor interop with active AccessControl (FastDDS/Cyclone/RTI
189    /// validate the permissions signature against the permissions_ca).
190    pub fn set_local_permissions(&mut self, permissions_p7s: Vec<u8>) {
191        self.local_permissions = permissions_p7s;
192    }
193
194    /// Sets the preferred key agreement as initiator. Only for a deliberate
195    /// vendor-extension choice (e.g. `KxSuite::X25519` between pure ZeroDDS
196    /// peers) — the default `EcdhP256` is the spec/cross-vendor choice and
197    /// should NOT be changed for interop.
198    pub fn set_preferred_kx_suite(&mut self, suite: KxSuite) {
199        self.preferred_kx_suite = suite;
200    }
201
202    /// For OpenDDS peers (see [`Self::algo_nul`]) appends a `\0` to an
203    /// algorithm string — consistent for the wire AND the hash (both use
204    /// `.as_bytes()`). Otherwise unchanged.
205    fn algo_str(&self, s: &str) -> alloc::string::String {
206        if self.algo_nul {
207            alloc::format!("{s}\0")
208        } else {
209            s.into()
210        }
211    }
212
213    fn next_id(&self) -> u64 {
214        self.next_handle.fetch_add(1, Ordering::Relaxed) + 1
215    }
216
217    /// Maps a `c.kagree_algo` string to the KX suite. `None` =
218    /// unsupported (the replier then rejects). Any trailing `\0`
219    /// (OpenDDS sends the algorithm strings NUL-terminated) is removed before
220    /// the comparison — the KX suite is independent of it.
221    fn kx_suite_for_algo(algo: &str) -> Option<KxSuite> {
222        let algo = algo.trim_end_matches('\0');
223        if algo == ht::algo::ECDHE_P256_SHA256 {
224            Some(KxSuite::EcdhP256)
225        } else if algo == ht::algo::X25519 {
226            Some(KxSuite::X25519)
227        } else {
228            None
229        }
230    }
231
232    /// `c.kagree_algo` string for a KX suite.
233    fn kagree_algo_str(suite: KxSuite) -> &'static str {
234        match suite {
235            KxSuite::EcdhP256 => ht::algo::ECDHE_P256_SHA256,
236            KxSuite::X25519 => ht::algo::X25519,
237        }
238    }
239
240    /// Programmatic variant: pass `IdentityConfig` directly instead of
241    /// a `PropertyList`. Useful for tests + native
242    /// Rust callers.
243    ///
244    /// # Errors
245    /// See [`PkiError`].
246    pub fn validate_with_config(
247        &mut self,
248        cfg: IdentityConfig,
249        _participant_guid: [u8; 16],
250    ) -> SecurityResult<IdentityHandle> {
251        let parsed = ParsedIdentity::from_config(&cfg).map_err(pki_to_security)?;
252        let handle = IdentityHandle(self.next_id());
253        self.identities.insert(handle, parsed);
254        Ok(handle)
255    }
256
257    /// Reads the raw 32-byte SharedSecret bytes. Primarily for tests;
258    /// in the production path the `SharedSecretHandle` is passed through to the
259    /// CryptoPlugin without the caller seeing the bytes.
260    #[must_use]
261    pub fn secret_bytes(&self, handle: SharedSecretHandle) -> Option<&[u8]> {
262        self.secrets.get(&handle).map(Vec::as_slice)
263    }
264
265    fn store_secret(
266        &mut self,
267        _h: HandshakeHandle,
268        bytes: Vec<u8>,
269        challenge1: [u8; 32],
270        challenge2: [u8; 32],
271    ) -> SharedSecretHandle {
272        let handle = SharedSecretHandle(self.next_id());
273        self.secrets.insert(handle, bytes);
274        self.secret_challenges
275            .insert(handle, (challenge1, challenge2));
276        handle
277    }
278
279    fn record_challenge(&mut self, local: IdentityHandle, c: [u8; 32]) -> SecurityResult<()> {
280        let cache = self.replay_cache.entry(local).or_default();
281        if cache.contains(&c) {
282            return Err(SecurityError::new(
283                SecurityErrorKind::AuthenticationFailed,
284                "pki: replayed challenge1 detected",
285            ));
286        }
287        cache.insert(c);
288        let order = self.replay_order.entry(local).or_default();
289        order.push(c);
290        if order.len() > REPLAY_CACHE_CAP {
291            let dropped = order.remove(0);
292            cache.remove(&dropped);
293        }
294        Ok(())
295    }
296}
297
298impl SharedSecretProvider for PkiAuthenticationPlugin {
299    fn get_shared_secret(&self, handle: SharedSecretHandle) -> Option<Vec<u8>> {
300        self.secrets.get(&handle).cloned()
301    }
302
303    fn get_shared_secret_challenges(
304        &self,
305        handle: SharedSecretHandle,
306    ) -> Option<([u8; 32], [u8; 32])> {
307        self.secret_challenges.get(&handle).copied()
308    }
309}
310
311fn pki_to_security(e: PkiError) -> SecurityError {
312    let kind = match &e {
313        PkiError::InvalidPem(_) | PkiError::NoCertInPem => SecurityErrorKind::BadArgument,
314        PkiError::CertInvalid(_) => SecurityErrorKind::AuthenticationFailed,
315        PkiError::EmptyTrustAnchors => SecurityErrorKind::InvalidConfiguration,
316    };
317    SecurityError::new(kind, alloc::format!("pki: {e}"))
318}
319
320fn random_challenge() -> SecurityResult<[u8; 32]> {
321    let rng = SystemRandom::new();
322    let mut buf = [0u8; 32];
323    rng.fill(&mut buf).map_err(|_| {
324        SecurityError::new(
325            SecurityErrorKind::CryptoFailed,
326            "pki: SystemRandom not available",
327        )
328    })?;
329    Ok(buf)
330}
331
332fn algo_for(key_algo: CertKeyAlgo) -> SecurityResult<&'static str> {
333    match key_algo {
334        CertKeyAlgo::EcdsaP256Sha256 => Ok(ht::algo::ECDSA_SHA256),
335        CertKeyAlgo::RsaPssSha256 => Ok(ht::algo::RSASSA_PSS_SHA256),
336        CertKeyAlgo::Unknown => Err(SecurityError::new(
337            SecurityErrorKind::InvalidConfiguration,
338            "pki: cert public-key algo unsupported",
339        )),
340    }
341}
342
343fn check_dsign_matches(declared: &str, detected: CertKeyAlgo) -> SecurityResult<()> {
344    let expected = algo_for(detected)?;
345    // OpenDDS sends c.dsign_algo NUL-terminated (sizeof convention) — the
346    // trailing `\0` is not part of the algorithm name.
347    let declared = declared.trim_end_matches('\0');
348    if !declared.eq_ignore_ascii_case(expected) {
349        return Err(SecurityError::new(
350            SecurityErrorKind::InvalidConfiguration,
351            alloc::format!("pki: c.dsign_algo {declared} doesn't match cert (expected {expected})"),
352        ));
353    }
354    Ok(())
355}
356
357fn sign_with(key_algo: CertKeyAlgo, pkcs8: &[u8], msg: &[u8]) -> SecurityResult<Vec<u8>> {
358    let rng = SystemRandom::new();
359    match key_algo {
360        CertKeyAlgo::EcdsaP256Sha256 => {
361            let key = crate::compat::ecdsa_from_pkcs8(
362                &signature::ECDSA_P256_SHA256_ASN1_SIGNING,
363                pkcs8,
364                &rng,
365            )
366            .map_err(|e| {
367                SecurityError::new(
368                    SecurityErrorKind::CryptoFailed,
369                    alloc::format!("pki: ecdsa key-parse failed: {e}"),
370                )
371            })?;
372            let sig = key.sign(&rng, msg).map_err(|e| {
373                SecurityError::new(
374                    SecurityErrorKind::CryptoFailed,
375                    alloc::format!("pki: ecdsa sign failed: {e}"),
376                )
377            })?;
378            Ok(sig.as_ref().to_vec())
379        }
380        CertKeyAlgo::RsaPssSha256 => {
381            let key = signature::RsaKeyPair::from_pkcs8(pkcs8).map_err(|e| {
382                SecurityError::new(
383                    SecurityErrorKind::CryptoFailed,
384                    alloc::format!("pki: rsa key-parse failed: {e}"),
385                )
386            })?;
387            let mut out = alloc::vec![0u8; crate::compat::rsa_modulus_len(&key)];
388            key.sign(&signature::RSA_PSS_SHA256, &rng, msg, &mut out)
389                .map_err(|e| {
390                    SecurityError::new(
391                        SecurityErrorKind::CryptoFailed,
392                        alloc::format!("pki: rsa sign failed: {e}"),
393                    )
394                })?;
395            Ok(out)
396        }
397        CertKeyAlgo::Unknown => Err(SecurityError::new(
398            SecurityErrorKind::InvalidConfiguration,
399            "pki: cannot sign — unsupported key algo",
400        )),
401    }
402}
403
404fn verify_signature_with_cert(
405    cert_der: &[u8],
406    key_algo: CertKeyAlgo,
407    msg: &[u8],
408    sig: &[u8],
409) -> SecurityResult<()> {
410    let cert = CertificateDer::from_slice(cert_der);
411    let ee = webpki::EndEntityCert::try_from(&cert).map_err(|e| {
412        SecurityError::new(
413            SecurityErrorKind::AuthenticationFailed,
414            alloc::format!("pki: peer cert parse failed: {e:?}"),
415        )
416    })?;
417    let alg: &dyn rustls_pki_types::SignatureVerificationAlgorithm = match key_algo {
418        #[cfg(not(feature = "aws-lc"))]
419        CertKeyAlgo::EcdsaP256Sha256 => webpki::ring::ECDSA_P256_SHA256,
420        #[cfg(feature = "aws-lc")]
421        CertKeyAlgo::EcdsaP256Sha256 => webpki::aws_lc_rs::ECDSA_P256_SHA256,
422        #[cfg(not(feature = "aws-lc"))]
423        CertKeyAlgo::RsaPssSha256 => webpki::ring::RSA_PSS_2048_8192_SHA256_LEGACY_KEY,
424        #[cfg(feature = "aws-lc")]
425        CertKeyAlgo::RsaPssSha256 => webpki::aws_lc_rs::RSA_PSS_2048_8192_SHA256_LEGACY_KEY,
426        CertKeyAlgo::Unknown => {
427            return Err(SecurityError::new(
428                SecurityErrorKind::InvalidConfiguration,
429                "pki: peer cert algo unsupported",
430            ));
431        }
432    };
433    ee.verify_signature(alg, msg, sig).map_err(|e| {
434        SecurityError::new(
435            SecurityErrorKind::AuthenticationFailed,
436            alloc::format!("pki: signature verify failed: {e:?}"),
437        )
438    })
439}
440
441/// Detect peer cert algo from DER (re-uses identity helper logic via
442/// trial-parse; pragmatic — we duplicate the SPKI scan locally to avoid
443/// re-exporting an internal helper).
444fn detect_peer_algo(cert_der: &[u8]) -> CertKeyAlgo {
445    crate::identity::detect_cert_algo_pub(cert_der)
446}
447
448/// PEM-encode a DER certificate. The handshake `c.id` carries the cert as
449/// **PEM** (spec §9.3.2.5.2; cyclone/FastDDS/RTI parse c.id as PEM and
450/// fail on raw DER with "PEM routines: no start line").
451fn der_to_pem(der: &[u8]) -> Vec<u8> {
452    use x509_cert::Certificate;
453    use x509_cert::der::{Decode, EncodePem, pem::LineEnding};
454    match Certificate::from_der(der).and_then(|c| c.to_pem(LineEnding::LF)) {
455        Ok(pem) => pem.into_bytes(),
456        Err(_) => der.to_vec(),
457    }
458}
459
460/// Normalizes received `c.id` bytes to DER for the webpki cert crypto:
461/// cyclone/FastDDS send PEM, ZeroDDS legacy/internal possibly raw DER. The
462/// hash check (compute_hash_c) still runs over the RAW `c.id` bytes (as
463/// on the wire), only the cryptographic verification needs DER.
464fn cid_to_der(bytes: &[u8]) -> Vec<u8> {
465    use x509_cert::Certificate;
466    use x509_cert::der::{DecodePem, Encode};
467    // OpenDDS appends a NUL byte to c.id (cert `original_bytes`)
468    // (`Certificate::load_cert_bytes`: `length(i + 1)`). webpki rejects that
469    // on the DER side as `TrailingData(SignedData)` — strip trailing NULs.
470    // ONLY for parsing: hash_c1/hash_c2 still use the RAW c.id bytes
471    // (with NUL), so the hash matches OpenDDS byte for byte.
472    let bytes = match bytes.iter().rposition(|&b| b != 0) {
473        Some(i) => &bytes[..=i],
474        None => bytes,
475    };
476    if bytes.starts_with(b"-----") {
477        if let Ok(cert) = Certificate::from_pem(bytes) {
478            if let Ok(der) = cert.to_der() {
479                return der;
480            }
481        }
482    }
483    bytes.to_vec()
484}
485
486/// Derives the 32-byte `SharedSecret` from the raw DH output.
487///
488/// DDS-Security §9.3.2.5 + cyclone `generate_shared_secret` (authentication.c):
489/// `SharedSecret = SHA256(raw_dh)`. **No** HKDF, **no** challenge salt —
490/// the earlier ZeroDDS variant (`HKDF(raw_dh, salt=ch1||ch2)`) produced a
491/// different secret cross-vendor than cyclone/FastDDS and thereby broke the
492/// downstream crypto/VolatileSecure key exchange (the handshake itself
493/// verified, because the signature does not cover the SharedSecret). challenge1/
494/// challenge2 are carried separately (by the caller) for the VolatileSecure key derivation
495/// (§9.5.3.5), not mixed in here.
496fn derive_shared_secret(raw_dh: &[u8]) -> SecurityResult<Vec<u8>> {
497    let d = digest::digest(&digest::SHA256, raw_dh);
498    Ok(d.as_ref().to_vec())
499}
500
501impl AuthenticationPlugin for PkiAuthenticationPlugin {
502    fn validate_local_identity(
503        &mut self,
504        props: &PropertyList,
505        participant_guid: [u8; 16],
506    ) -> SecurityResult<IdentityHandle> {
507        let cert = props.get(keys::IDENTITY_CERT).ok_or_else(|| {
508            SecurityError::new(
509                SecurityErrorKind::InvalidConfiguration,
510                "pki: missing dds.sec.auth.identity_certificate",
511            )
512        })?;
513        let ca = props.get(keys::IDENTITY_CA).ok_or_else(|| {
514            SecurityError::new(
515                SecurityErrorKind::InvalidConfiguration,
516                "pki: missing dds.sec.auth.identity_ca",
517            )
518        })?;
519        let cfg = IdentityConfig {
520            identity_cert_pem: cert.as_bytes().to_vec(),
521            identity_ca_pem: ca.as_bytes().to_vec(),
522            identity_key_pem: props.get(keys::IDENTITY_KEY).map(|s| s.as_bytes().to_vec()),
523        };
524        self.validate_with_config(cfg, participant_guid)
525    }
526
527    fn validate_remote_identity(
528        &mut self,
529        local: IdentityHandle,
530        _remote_participant_guid: [u8; 16],
531        remote_auth_token: &[u8],
532    ) -> SecurityResult<IdentityHandle> {
533        let parsed = self.identities.get(&local).ok_or_else(|| {
534            SecurityError::new(
535                SecurityErrorKind::BadArgument,
536                "pki: unknown local IdentityHandle",
537            )
538        })?;
539        // Two accepted token forms:
540        // 1. Spec `IdentityToken` descriptor (PID_IDENTITY_TOKEN from SPDP,
541        //    cross-vendor): only cert SN/algo, no cert. Acceptance gate —
542        //    the cryptographic cert-chain validation happens in the
543        //    handshake (verify_remote_der on `c.id`, where the real cert
544        //    arrives). Both handshake sides already validate.
545        // 2. Raw cert DER (ZeroDDS-internal/legacy caller): check immediately against
546        //    the local trust anchors.
547        // A spec IdentityToken descriptor is recognized by its class_id
548        // (DataHolder), NOT by the optional cert properties: cyclone/
549        // FastDDS send a MINIMAL token in SPDP (only class_id
550        // "DDS:Auth:PKI-DH:x.y", empty properties). Only if it is NOT such a
551        // descriptor (= raw cert DER from ZeroDDS-internal/legacy
552        // callers) check immediately against the local trust anchors. The
553        // cryptographic cert-chain validation of the remote happens anyway
554        // only in the handshake (verify_remote_der on `c.id`, where the real cert
555        // arrives). Previously there was IdentityToken::decode here (requires ALL 4
556        // cert properties) — that threw cyclone's minimal token into the
557        // cert-DER path → AuthenticationFailed "TrailingData(SignedData)" →
558        // ZeroDDS sent no AUTH_REQUEST cross-vendor.
559        let is_spec_descriptor =
560            zerodds_security::token::DataHolder::from_cdr_le(remote_auth_token)
561                .map(|dh| dh.class_id.starts_with("DDS:Auth:PKI-DH"))
562                .unwrap_or(false);
563        if !is_spec_descriptor {
564            parsed
565                .verify_remote_der(remote_auth_token)
566                .map_err(pki_to_security)?;
567        }
568        let handle = IdentityHandle(self.next_id());
569        Ok(handle)
570    }
571
572    fn get_identity_token(&self, local: IdentityHandle) -> SecurityResult<Vec<u8>> {
573        let parsed = self.identities.get(&local).ok_or_else(|| {
574            SecurityError::new(
575                SecurityErrorKind::BadArgument,
576                "pki: unknown local IdentityHandle",
577            )
578        })?;
579        let ca_der = parsed.trust_anchors_der.first().ok_or_else(|| {
580            SecurityError::new(SecurityErrorKind::BadArgument, "pki: no trust anchor")
581        })?;
582        let token = crate::identity_token::build_identity_token_from_der(&parsed.cert_der, ca_der)
583            .map_err(pki_to_security)?;
584        Ok(token.encode())
585    }
586
587    fn get_permissions_token(&self) -> Vec<u8> {
588        // Without configured permissions AccessControl is inactive — then
589        // the token is omitted (no empty permissions-match trigger).
590        if self.local_permissions.is_empty() {
591            return Vec::new();
592        }
593        // class_id-only PermissionsToken (spec §7.2.4). Version string
594        // `1.0` + empty properties mirror what cyclone/FastDDS announce
595        // in SPDP (`permissions_token="DDS:Access:Permissions:1.0"
596        // :{}:{}`). The actual signed permissions content travels
597        // in the handshake as `c.perm`, not in the SPDP token.
598        ht::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
599    }
600
601    fn set_local_participant_data(&mut self, pdata: Vec<u8>) {
602        self.local_pdata = pdata;
603    }
604
605    fn set_algo_nul_terminate(&mut self, nul: bool) {
606        self.algo_nul = nul;
607    }
608
609    fn begin_handshake_request(
610        &mut self,
611        initiator: IdentityHandle,
612        _replier: IdentityHandle,
613    ) -> SecurityResult<(HandshakeHandle, HandshakeStepOutcome)> {
614        let parsed = self.identities.get(&initiator).ok_or_else(|| {
615            SecurityError::new(
616                SecurityErrorKind::BadArgument,
617                "pki: unknown initiator IdentityHandle",
618            )
619        })?;
620        // c.id carries the cert as PEM (spec §9.3.2.5.2 / cross-vendor);
621        // hash_c1 is computed in build_request_token consistently over the same
622        // PEM bytes.
623        let cert_der = der_to_pem(&parsed.cert_der);
624        let key_algo = parsed.key_algo;
625        let dsign_algo = self.algo_str(algo_for(key_algo)?);
626        // Spec/cross-vendor default (EcdhP256) instead of the non-spec X25519
627        // extension. The announced `c.kagree_algo` must match the actually
628        // used suite.
629        let suite = self.preferred_kx_suite;
630        let kagree_algo = self.algo_str(Self::kagree_algo_str(suite));
631
632        let kx = KeyExchange::with_suite(suite)?;
633        let dh1 = kx.public_key().to_vec();
634        let challenge1 = random_challenge()?;
635        // c.perm: local CMS-signed permissions document (cross-vendor
636        // AccessControl requirement). c.pdata: own ParticipantBuiltinTopicData
637        // as PL_CDR (the replier deserializes it as a ParameterList).
638        let permissions: Vec<u8> = self.local_permissions.clone();
639        let pdata: Vec<u8> = self.local_pdata.clone();
640
641        let bytes = ht::build_request_token(&RequestBuildInput {
642            cert_der: &cert_der,
643            permissions: &permissions,
644            pdata: &pdata,
645            dsign_algo: &dsign_algo,
646            kagree_algo: &kagree_algo,
647            dh1: &dh1,
648            challenge1: &challenge1,
649            ocsp_status: &[],
650        })?;
651
652        let hash_c1 =
653            ht::compute_hash_c(&cert_der, &permissions, &pdata, &dsign_algo, &kagree_algo);
654
655        let handle = HandshakeHandle(self.next_id());
656        self.pending_initiator.insert(
657            handle,
658            InitiatorState {
659                local: initiator,
660                kx: Some(kx),
661                dh1,
662                challenge1,
663                hash_c1,
664                permissions,
665                pdata,
666                kagree_algo,
667                dsign_algo,
668            },
669        );
670        Ok((handle, HandshakeStepOutcome::SendMessage { token: bytes }))
671    }
672
673    fn begin_handshake_reply(
674        &mut self,
675        replier: IdentityHandle,
676        _initiator: IdentityHandle,
677        request_token: &[u8],
678    ) -> SecurityResult<(HandshakeHandle, HandshakeStepOutcome)> {
679        // 1) Parse + hash re-validation (happens in parse_request_token).
680        let req = ht::parse_request_token(request_token)?;
681        // Echo kagree for the reply: `req.kagree_algo` is NUL-stripped on
682        // parsing; for OpenDDS peers (algo_nul) restore the NUL form
683        // so that the wire (DiffieHellman::factory sizeof comparison) AND
684        // hash_c2 are consistently NUL-terminated. For FastDDS/Cyclone NUL-free.
685        let reply_kagree = self.algo_str(&req.kagree_algo);
686
687        // 2) Validate the initiator cert against our trust store.
688        let parsed = self.identities.get(&replier).ok_or_else(|| {
689            SecurityError::new(
690                SecurityErrorKind::BadArgument,
691                "pki: unknown replier IdentityHandle",
692            )
693        })?;
694        parsed
695            .verify_remote_der(&cid_to_der(&req.cert_der))
696            .map_err(pki_to_security)?;
697
698        // 3) Replay detection.
699        self.record_challenge(replier, req.challenge1)?;
700
701        // 4) The initiator `c.dsign_algo` must match its cert public key.
702        let initiator_key_algo = detect_peer_algo(&cid_to_der(&req.cert_der));
703        check_dsign_matches(&req.dsign_algo, initiator_key_algo)?;
704
705        // 5) Generate our own DH pair + challenge2. The KX suite follows the
706        //    `c.kagree_algo` announced by the initiator (spec: the replier
707        //    uses the same mechanism) — unsupported = reject.
708        let suite = Self::kx_suite_for_algo(&req.kagree_algo).ok_or_else(|| {
709            SecurityError::new(
710                SecurityErrorKind::AuthenticationFailed,
711                "pki: unsupported c.kagree_algo in the request",
712            )
713        })?;
714        let kx = KeyExchange::with_suite(suite)?;
715        let dh2 = kx.public_key().to_vec();
716        let challenge2 = random_challenge()?;
717
718        // 6) DH agreement → SharedSecret.
719        let parsed = self.identities.get(&replier).ok_or_else(|| {
720            SecurityError::new(SecurityErrorKind::Internal, "pki: replier identity gone")
721        })?;
722        // Sign with the replier key
723        let priv_key = parsed.private_key_pkcs8_der.clone().ok_or_else(|| {
724            SecurityError::new(
725                SecurityErrorKind::InvalidConfiguration,
726                "pki: replier has no private key configured (cannot sign)",
727            )
728        })?;
729        let replier_cert_der = der_to_pem(&parsed.cert_der);
730        let replier_dsign = self.algo_str(algo_for(parsed.key_algo)?);
731        let replier_key_algo = parsed.key_algo;
732
733        // raw DH output (KeyExchange consumes self).
734        let secret_bytes = kx.derive_shared_secret(&req.dh1)?;
735        // HKDF-Re-Derive with challenges as salt for spec-aligned key.
736        let final_secret = derive_shared_secret(&secret_bytes)?;
737
738        // 7) Replier hash_c2 = over the replier tuple (echo kagree, own
739        //    cert/perm/pdata/dsign). c.perm = local permissions document.
740        //    Needed both in the reply token and in the signature content.
741        let permissions: Vec<u8> = self.local_permissions.clone();
742        let pdata: Vec<u8> = self.local_pdata.clone();
743        let hash_c2 = ht::compute_hash_c(
744            &replier_cert_der,
745            &permissions,
746            &pdata,
747            &replier_dsign,
748            &reply_kagree,
749        );
750
751        // 8) Reply signature (DDS-Security §9.3.2.5.2.2): the replier signs over
752        //    BinaryPropertySeq{ hash_c2, ch2, dh2, ch1, dh1, hash_c1 }.
753        let to_sign = ht::reply_signing_bytes(
754            &hash_c2,
755            &challenge2,
756            &dh2,
757            &req.challenge1,
758            &req.dh1,
759            &req.hash_c1,
760        );
761        let signature = sign_with(replier_key_algo, &priv_key, &to_sign)?;
762
763        // 9) Build the reply token.
764        let reply_bytes = ht::build_reply_token(&ReplyBuildInput {
765            cert_der: &replier_cert_der,
766            permissions: &permissions,
767            pdata: &pdata,
768            dsign_algo: &replier_dsign,
769            kagree_algo: &reply_kagree,
770            dh2: &dh2,
771            challenge2: &challenge2,
772            hash_c1: &req.hash_c1,
773            dh1: &req.dh1,
774            challenge1: &req.challenge1,
775            ocsp_status: &[],
776            signature: &signature,
777        })?;
778
779        // 10) Persist state — the final-receive needs it.
780        // challenge1 = initiator (req), challenge2 = own replier value.
781        let handle = HandshakeHandle(self.next_id());
782        let secret_handle = self.store_secret(handle, final_secret, req.challenge1, challenge2);
783        self.handshake_to_secret.insert(handle, secret_handle);
784        self.pending_replier.insert(
785            handle,
786            ReplierState {
787                local: replier,
788                challenge1: req.challenge1,
789                challenge2,
790                dh1: req.dh1,
791                dh2,
792                hash_c1: req.hash_c1,
793                hash_c2,
794                secret_handle,
795                initiator_cert_der: req.cert_der,
796                initiator_key_algo,
797            },
798        );
799
800        Ok((
801            handle,
802            HandshakeStepOutcome::SendMessage { token: reply_bytes },
803        ))
804    }
805
806    fn process_handshake(
807        &mut self,
808        handshake: HandshakeHandle,
809        token: &[u8],
810    ) -> SecurityResult<HandshakeStepOutcome> {
811        // The initiator side has a pending_initiator entry → token = REPLY.
812        if self.pending_initiator.contains_key(&handshake) {
813            return self.process_reply_on_initiator(handshake, token);
814        }
815        // The replier side has a pending_replier entry → token = FINAL.
816        if self.pending_replier.contains_key(&handshake) {
817            return self.process_final_on_replier(handshake, token);
818        }
819        Err(SecurityError::new(
820            SecurityErrorKind::BadArgument,
821            "pki: unknown HandshakeHandle",
822        ))
823    }
824
825    fn shared_secret(&self, handshake: HandshakeHandle) -> SecurityResult<SharedSecretHandle> {
826        self.handshake_to_secret
827            .get(&handshake)
828            .copied()
829            .ok_or_else(|| {
830                SecurityError::new(
831                    SecurityErrorKind::BadArgument,
832                    "pki: handshake handle unknown or not yet completed",
833                )
834            })
835    }
836
837    fn plugin_class_id(&self) -> &str {
838        "DDS:Auth:PKI-DH:1.2"
839    }
840}
841
842impl PkiAuthenticationPlugin {
843    fn process_reply_on_initiator(
844        &mut self,
845        handshake: HandshakeHandle,
846        token: &[u8],
847    ) -> SecurityResult<HandshakeStepOutcome> {
848        let reply = ht::parse_reply_token(token)?;
849
850        let st = self.pending_initiator.remove(&handshake).ok_or_else(|| {
851            SecurityError::new(SecurityErrorKind::BadArgument, "pki: initiator state gone")
852        })?;
853
854        // a) Echo consistency: challenge1 must match 1:1. hash_c1/dh1 are
855        //    optional echo fields — cyclone/FastDDS omit them; if
856        //    present, check against our own state (cert-bind).
857        // hash_c1 echo (§9.3.2.3.2, cert-bind): cyclone omits hash_c1 in the reply;
858        // FastDDS mirrors its recompute of the request. Both compute
859        // hash_c1 over the `c.*` properties in WIRE order — since ZeroDDS
860        // emits the request in hash order (c.id, c.perm, c.pdata, c.dsign_algo,
861        // c.kagree_algo), the echo matches st.hash_c1 again.
862        if let Some(reply_hash_c1) = reply.hash_c1 {
863            if !ct_eq(&reply_hash_c1, &st.hash_c1) {
864                return Err(SecurityError::new(
865                    SecurityErrorKind::AuthenticationFailed,
866                    "reply: hash_c1 echo mismatch (cert-bind broken)",
867                ));
868            }
869        }
870        if let Some(reply_dh1) = &reply.dh1 {
871            if !ct_eq(reply_dh1, &st.dh1) {
872                return Err(SecurityError::new(
873                    SecurityErrorKind::AuthenticationFailed,
874                    "reply: dh1 echo mismatch",
875                ));
876            }
877        }
878        if !ct_eq(&reply.challenge1, &st.challenge1) {
879            return Err(SecurityError::new(
880                SecurityErrorKind::AuthenticationFailed,
881                "reply: challenge1 echo mismatch",
882            ));
883        }
884        // Echo comparison NUL-tolerant: `st.kagree_algo` keeps the (OpenDDS)
885        // NUL for hash_c1 consistency, `reply.kagree_algo` is already
886        // NUL-stripped on parsing (take_bin_string). The algorithm name
887        // itself must match.
888        if reply.kagree_algo.trim_end_matches('\0') != st.kagree_algo.trim_end_matches('\0') {
889            return Err(SecurityError::new(
890                SecurityErrorKind::AuthenticationFailed,
891                "reply: kagree_algo mismatch",
892            ));
893        }
894
895        // b) Validate the replier cert.
896        // Snapshot of the local identity so that we can borrow it
897        // mutably later (store_secret).
898        let (priv_key, initiator_key_algo) = {
899            let parsed = self.identities.get(&st.local).ok_or_else(|| {
900                SecurityError::new(SecurityErrorKind::Internal, "pki: initiator identity gone")
901            })?;
902            parsed
903                .verify_remote_der(&cid_to_der(&reply.cert_der))
904                .map_err(pki_to_security)?;
905            let pk = parsed.private_key_pkcs8_der.clone().ok_or_else(|| {
906                SecurityError::new(
907                    SecurityErrorKind::InvalidConfiguration,
908                    "pki: initiator has no private key (final-sign not possible)",
909                )
910            })?;
911            (pk, parsed.key_algo)
912        };
913        let replier_key_algo = detect_peer_algo(&cid_to_der(&reply.cert_der));
914        check_dsign_matches(&reply.dsign_algo, replier_key_algo)?;
915
916        // c) Verify the replier signature (§9.3.2.5.2.2): BinaryPropertySeq{
917        //    hash_c2, ch2, dh2, ch1, dh1, hash_c1 }. dh1/hash_c1 = the values
918        //    sent by the initiator (= st.dh1/st.hash_c1); the replier
919        //    signs over them, in the reply itself they are optionally omitted.
920        //    hash_c2 is the value recomputed from the reply credentials.
921        let to_verify = ht::reply_signing_bytes(
922            &reply.hash_c2,
923            &reply.challenge2,
924            &reply.dh2,
925            &reply.challenge1,
926            &st.dh1,
927            &st.hash_c1,
928        );
929        verify_signature_with_cert(
930            &cid_to_der(&reply.cert_der),
931            replier_key_algo,
932            &to_verify,
933            &reply.signature,
934        )?;
935
936        // d) DH agreement.
937        let kx = st.kx.ok_or_else(|| {
938            SecurityError::new(SecurityErrorKind::Internal, "pki: ephemeral kx gone")
939        })?;
940        let raw = kx.derive_shared_secret(&reply.dh2)?;
941        let final_secret = derive_shared_secret(&raw)?;
942
943        // challenge1 = own initiator value (st), challenge2 = replier (reply).
944        let secret_handle =
945            self.store_secret(handshake, final_secret, st.challenge1, reply.challenge2);
946        self.handshake_to_secret.insert(handshake, secret_handle);
947
948        // e) Build + sign the final token (§9.3.2.5.2.3): the initiator signs
949        //    over BinaryPropertySeq{ hash_c1, ch1, dh1, ch2, dh2, hash_c2 }.
950        let to_sign = ht::final_signing_bytes(
951            &st.hash_c1,
952            &reply.challenge1,
953            &st.dh1,
954            &reply.challenge2,
955            &reply.dh2,
956            &reply.hash_c2,
957        );
958        let signature = sign_with(initiator_key_algo, &priv_key, &to_sign)?;
959        // The final token echoes hash_c1/dh1 with the OWN initiator values
960        // (st), not the ones optionally omitted in the reply; hash_c2 is the
961        // value recomputed from the reply.
962        let final_token = ht::build_final_token(&FinalBuildInput {
963            hash_c1: &st.hash_c1,
964            hash_c2: &reply.hash_c2,
965            dh1: &st.dh1,
966            dh2: &reply.dh2,
967            challenge1: &reply.challenge1,
968            challenge2: &reply.challenge2,
969            ocsp_status: &[],
970            signature: &signature,
971        })?;
972
973        // Spec §10.3.2.10: the initiator sends `final_token` AND is
974        // **complete**. We tunnel both via `SendMessage` + lookup
975        // via `shared_secret()`. So that the wire layer sees the token,
976        // we return SendMessage; the DCPS runtime then calls
977        // `shared_secret()`.
978        Ok(HandshakeStepOutcome::SendMessage { token: final_token })
979    }
980
981    fn process_final_on_replier(
982        &mut self,
983        handshake: HandshakeHandle,
984        token: &[u8],
985    ) -> SecurityResult<HandshakeStepOutcome> {
986        let final_tok = ht::parse_final_token(token)?;
987        let st = self.pending_replier.remove(&handshake).ok_or_else(|| {
988            SecurityError::new(SecurityErrorKind::BadArgument, "pki: replier state gone")
989        })?;
990
991        // Echo consistency.
992        if !ct_eq(&final_tok.hash_c1, &st.hash_c1)
993            || !ct_eq(&final_tok.hash_c2, &st.hash_c2)
994            || !ct_eq(&final_tok.dh1, &st.dh1)
995            || !ct_eq(&final_tok.dh2, &st.dh2)
996            || !ct_eq(&final_tok.challenge1, &st.challenge1)
997            || !ct_eq(&final_tok.challenge2, &st.challenge2)
998        {
999            return Err(SecurityError::new(
1000                SecurityErrorKind::AuthenticationFailed,
1001                "final: echo mismatch",
1002            ));
1003        }
1004
1005        // Initiator signature (§9.3.2.5.2.3): BinaryPropertySeq{ hash_c1, ch1,
1006        // dh1, ch2, dh2, hash_c2 }.
1007        let to_verify = ht::final_signing_bytes(
1008            &st.hash_c1,
1009            &st.challenge1,
1010            &st.dh1,
1011            &st.challenge2,
1012            &st.dh2,
1013            &st.hash_c2,
1014        );
1015        verify_signature_with_cert(
1016            &cid_to_der(&st.initiator_cert_der),
1017            st.initiator_key_algo,
1018            &to_verify,
1019            &final_tok.signature,
1020        )?;
1021
1022        // We take the local handle only because of the `local` field, so the
1023        // lint does not complain; in production we could use it for
1024        // audit logging.
1025        let _ = st.local;
1026        Ok(HandshakeStepOutcome::Complete {
1027            secret: st.secret_handle,
1028        })
1029    }
1030}
1031
1032#[cfg(test)]
1033#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1034mod tests {
1035    use super::*;
1036    use zerodds_security::properties::Property;
1037
1038    /// Creates a CA + end-entity cert + the matching private key
1039    /// (PKCS8-PEM) — all for the rcgen default ECDSA-P256.
1040    fn make_signed_cert_ca_key() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1041        use rcgen::{CertificateParams, KeyPair};
1042
1043        let mut ca_params = CertificateParams::new(vec!["ZeroDDS Test CA".into()]).unwrap();
1044        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1045        let ca_key = KeyPair::generate().unwrap();
1046        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
1047        let ca_pem = ca_cert.pem();
1048
1049        let mut ee_params = CertificateParams::new(vec!["zerodds-node".into()]).unwrap();
1050        ee_params.is_ca = rcgen::IsCa::NoCa;
1051        let ee_key = KeyPair::generate().unwrap();
1052        let ee_cert = ee_params.signed_by(&ee_key, &ca_cert, &ca_key).unwrap();
1053        let ee_pem = ee_cert.pem();
1054        let ee_key_pem = ee_key.serialize_pem();
1055
1056        (
1057            ee_pem.into_bytes(),
1058            ca_pem.into_bytes(),
1059            ee_key_pem.into_bytes(),
1060        )
1061    }
1062
1063    fn make_cert_with_wrong_ca() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1064        use rcgen::{CertificateParams, KeyPair};
1065
1066        let mut trusted_ca_params = CertificateParams::new(vec!["Trusted CA".into()]).unwrap();
1067        trusted_ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1068        let trusted_ca_key = KeyPair::generate().unwrap();
1069        let trusted_ca_cert = trusted_ca_params.self_signed(&trusted_ca_key).unwrap();
1070
1071        let mut rogue_ca_params = CertificateParams::new(vec!["Rogue CA".into()]).unwrap();
1072        rogue_ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1073        let rogue_ca_key = KeyPair::generate().unwrap();
1074        let rogue_ca_cert = rogue_ca_params.self_signed(&rogue_ca_key).unwrap();
1075
1076        let mut ee_params = CertificateParams::new(vec!["impersonator".into()]).unwrap();
1077        ee_params.is_ca = rcgen::IsCa::NoCa;
1078        let ee_key = KeyPair::generate().unwrap();
1079        let ee_cert = ee_params
1080            .signed_by(&ee_key, &rogue_ca_cert, &rogue_ca_key)
1081            .unwrap();
1082
1083        (
1084            ee_cert.pem().into_bytes(),
1085            trusted_ca_cert.pem().into_bytes(),
1086            ee_key.serialize_pem().into_bytes(),
1087        )
1088    }
1089
1090    fn alice_bob() -> (
1091        PkiAuthenticationPlugin,
1092        PkiAuthenticationPlugin,
1093        IdentityHandle,
1094        IdentityHandle,
1095        IdentityHandle,
1096        IdentityHandle,
1097    ) {
1098        let (a_cert, ca, a_key) = make_signed_cert_ca_key();
1099        let (b_cert, _ca2, b_key) = make_signed_cert_ca_key();
1100        // Both use their own CAs, but we would have to pack both into the
1101        // peer's trust bundle for the cert chain to be
1102        // valid. Instead: both take Alice's CA as the
1103        // trust anchor and Bob's cert is faked under this CA.
1104        // Simpler: regenerate both with a shared CA.
1105        let _ = (b_cert, b_key);
1106
1107        // Cleaner approach: shared CA.
1108        use rcgen::{CertificateParams, KeyPair};
1109        let mut ca_params = CertificateParams::new(vec!["Common CA".into()]).unwrap();
1110        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1111        let ca_key = KeyPair::generate().unwrap();
1112        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
1113        let ca_pem = ca_cert.pem().into_bytes();
1114
1115        let mut alice_params = CertificateParams::new(vec!["alice".into()]).unwrap();
1116        alice_params.is_ca = rcgen::IsCa::NoCa;
1117        let alice_key_pair = KeyPair::generate().unwrap();
1118        let alice_cert = alice_params
1119            .signed_by(&alice_key_pair, &ca_cert, &ca_key)
1120            .unwrap();
1121        let alice_cert_pem = alice_cert.pem().into_bytes();
1122        let alice_key_pem = alice_key_pair.serialize_pem().into_bytes();
1123
1124        let mut bob_params = CertificateParams::new(vec!["bob".into()]).unwrap();
1125        bob_params.is_ca = rcgen::IsCa::NoCa;
1126        let bob_key_pair = KeyPair::generate().unwrap();
1127        let bob_cert = bob_params
1128            .signed_by(&bob_key_pair, &ca_cert, &ca_key)
1129            .unwrap();
1130        let bob_cert_pem = bob_cert.pem().into_bytes();
1131        let bob_key_pem = bob_key_pair.serialize_pem().into_bytes();
1132
1133        let mut alice = PkiAuthenticationPlugin::new();
1134        let mut bob = PkiAuthenticationPlugin::new();
1135        let alice_h = alice
1136            .validate_with_config(
1137                IdentityConfig {
1138                    identity_cert_pem: alice_cert_pem.clone(),
1139                    identity_ca_pem: ca_pem.clone(),
1140                    identity_key_pem: Some(alice_key_pem),
1141                },
1142                [0xAA; 16],
1143            )
1144            .unwrap();
1145        let alice_remote_for_bob = alice
1146            .validate_remote_identity(alice_h, [0xBB; 16], &cert_der_from_pem(&bob_cert_pem))
1147            .unwrap();
1148        let bob_h = bob
1149            .validate_with_config(
1150                IdentityConfig {
1151                    identity_cert_pem: bob_cert_pem.clone(),
1152                    identity_ca_pem: ca_pem,
1153                    identity_key_pem: Some(bob_key_pem),
1154                },
1155                [0xBB; 16],
1156            )
1157            .unwrap();
1158        let bob_remote_for_alice = bob
1159            .validate_remote_identity(bob_h, [0xAA; 16], &cert_der_from_pem(&alice_cert_pem))
1160            .unwrap();
1161        let _ = (a_cert, ca, a_key);
1162        (
1163            alice,
1164            bob,
1165            alice_h,
1166            alice_remote_for_bob,
1167            bob_h,
1168            bob_remote_for_alice,
1169        )
1170    }
1171
1172    fn cert_der_from_pem(pem: &[u8]) -> Vec<u8> {
1173        use rustls_pki_types::CertificateDer;
1174        use rustls_pki_types::pem::PemObject;
1175        CertificateDer::pem_slice_iter(pem)
1176            .next()
1177            .unwrap()
1178            .unwrap()
1179            .as_ref()
1180            .to_vec()
1181    }
1182
1183    #[test]
1184    fn plugin_class_id_matches_spec() {
1185        let p = PkiAuthenticationPlugin::new();
1186        assert_eq!(p.plugin_class_id(), "DDS:Auth:PKI-DH:1.2");
1187    }
1188
1189    #[test]
1190    fn validate_local_identity_accepts_ca_signed_cert() {
1191        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1192        let mut plugin = PkiAuthenticationPlugin::new();
1193        let cfg = IdentityConfig {
1194            identity_cert_pem: cert_pem,
1195            identity_ca_pem: ca_pem,
1196            identity_key_pem: Some(key_pem),
1197        };
1198        let handle = plugin
1199            .validate_with_config(cfg, [0xAA; 16])
1200            .expect("signed cert must validate");
1201        assert_eq!(handle, IdentityHandle(1));
1202    }
1203
1204    #[test]
1205    fn validate_local_identity_rejects_wrong_ca() {
1206        let (cert_pem, trusted_ca_pem, key_pem) = make_cert_with_wrong_ca();
1207        let mut plugin = PkiAuthenticationPlugin::new();
1208        let cfg = IdentityConfig {
1209            identity_cert_pem: cert_pem,
1210            identity_ca_pem: trusted_ca_pem,
1211            identity_key_pem: Some(key_pem),
1212        };
1213        let err = plugin
1214            .validate_with_config(cfg, [0xAA; 16])
1215            .expect_err("rogue-CA cert must not validate");
1216        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1217    }
1218
1219    #[test]
1220    fn validate_local_identity_rejects_empty_trust_anchors() {
1221        let (cert_pem, _, _) = make_signed_cert_ca_key();
1222        let mut plugin = PkiAuthenticationPlugin::new();
1223        let cfg = IdentityConfig {
1224            identity_cert_pem: cert_pem,
1225            identity_ca_pem: b"".to_vec(),
1226            identity_key_pem: None,
1227        };
1228        let err = plugin.validate_with_config(cfg, [0xAA; 16]).unwrap_err();
1229        assert_eq!(err.kind, SecurityErrorKind::InvalidConfiguration);
1230    }
1231
1232    #[test]
1233    fn validate_local_identity_via_property_list() {
1234        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1235        let mut plugin = PkiAuthenticationPlugin::new();
1236        let cert_str = std::str::from_utf8(&cert_pem).unwrap().to_owned();
1237        let ca_str = std::str::from_utf8(&ca_pem).unwrap().to_owned();
1238        let key_str = std::str::from_utf8(&key_pem).unwrap().to_owned();
1239        let props = PropertyList::new()
1240            .with(Property::local(
1241                "dds.sec.auth.identity_certificate",
1242                cert_str,
1243            ))
1244            .with(Property::local("dds.sec.auth.identity_ca", ca_str))
1245            .with(Property::local("dds.sec.auth.private_key", key_str));
1246        let handle = plugin
1247            .validate_local_identity(&props, [0xAA; 16])
1248            .expect("validate via props");
1249        assert!(handle.0 >= 1);
1250    }
1251
1252    // -------------------------------------------------------------
1253    // C3.1 — spec-conformant handshake (Tab.56/57/58)
1254    // -------------------------------------------------------------
1255
1256    #[test]
1257    fn full_three_round_handshake_alice_bob() {
1258        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1259
1260        // 1) Alice → REQUEST.
1261        let (alice_hs, out1) = alice
1262            .begin_handshake_request(alice_h, alice_remote_bob)
1263            .unwrap();
1264        let req_token = match out1 {
1265            HandshakeStepOutcome::SendMessage { token } => token,
1266            _ => panic!("expected SendMessage"),
1267        };
1268        assert!(req_token.len() > 100, "request token contains cert + DH");
1269
1270        // 2) Bob → REPLY.
1271        let (bob_hs, out2) = bob
1272            .begin_handshake_reply(bob_h, bob_remote_alice, &req_token)
1273            .unwrap();
1274        let reply_token = match out2 {
1275            HandshakeStepOutcome::SendMessage { token } => token,
1276            _ => panic!("expected SendMessage"),
1277        };
1278
1279        // 3) Alice → FINAL.
1280        let out3 = alice.process_handshake(alice_hs, &reply_token).unwrap();
1281        let final_token = match out3 {
1282            HandshakeStepOutcome::SendMessage { token } => token,
1283            _ => panic!("alice expected to send final token"),
1284        };
1285
1286        // 4) Bob processes FINAL → Complete.
1287        let out4 = bob.process_handshake(bob_hs, &final_token).unwrap();
1288        let bob_secret = match out4 {
1289            HandshakeStepOutcome::Complete { secret } => secret,
1290            _ => panic!("expected Complete"),
1291        };
1292
1293        let alice_secret = alice.shared_secret(alice_hs).unwrap();
1294        let a_bytes = alice.secret_bytes(alice_secret).unwrap();
1295        let b_bytes = bob.secret_bytes(bob_secret).unwrap();
1296        assert_eq!(a_bytes.len(), 32);
1297        assert_eq!(
1298            a_bytes, b_bytes,
1299            "alice + bob must derive an identical secret"
1300        );
1301    }
1302
1303    #[test]
1304    fn request_token_has_spec_class_id_and_properties() {
1305        let (mut alice, _bob, alice_h, alice_remote_bob, _, _) = alice_bob();
1306        let (_, out) = alice
1307            .begin_handshake_request(alice_h, alice_remote_bob)
1308            .unwrap();
1309        let token = match out {
1310            HandshakeStepOutcome::SendMessage { token } => token,
1311            _ => panic!(),
1312        };
1313        let parsed = ht::DataHolder::from_cdr_le(&token).unwrap();
1314        assert_eq!(parsed.class_id, "DDS:Auth:PKI-DH:1.0+Req");
1315        // c.dsign_algo / c.kagree_algo are BINARY properties (§9.3.2.5.2.1).
1316        assert!(parsed.binary_property("c.dsign_algo").is_some());
1317        assert!(parsed.binary_property("c.kagree_algo").is_some());
1318        // ocsp_status is spec-optional and is NOT emitted cross-vendor
1319        // (FastDDS does not send it; see build_request_token).
1320        for k in ["c.id", "c.perm", "c.pdata", "hash_c1", "dh1", "challenge1"] {
1321            assert!(
1322                parsed.binary_property(k).is_some(),
1323                "missing binary prop: {k}"
1324            );
1325        }
1326    }
1327
1328    #[test]
1329    fn cert_bind_replier_modifies_initiator_cert_in_reply_rejected() {
1330        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1331        let (alice_hs, out1) = alice
1332            .begin_handshake_request(alice_h, alice_remote_bob)
1333            .unwrap();
1334        let req = match out1 {
1335            HandshakeStepOutcome::SendMessage { token } => token,
1336            _ => panic!(),
1337        };
1338        let (_bob_hs, out2) = bob
1339            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1340            .unwrap();
1341        let mut reply_token = match out2 {
1342            HandshakeStepOutcome::SendMessage { token } => token,
1343            _ => panic!(),
1344        };
1345
1346        // Tamper: flip a bit in hash_c1 → echo mismatch at the initiator.
1347        let mut h = ht::DataHolder::from_cdr_le(&reply_token).unwrap();
1348        let mut hash_c1 = h.binary_property("hash_c1").unwrap().to_vec();
1349        hash_c1[0] ^= 0x01;
1350        h.set_binary_property("hash_c1", hash_c1);
1351        reply_token = h.to_cdr_le();
1352
1353        let err = alice.process_handshake(alice_hs, &reply_token).unwrap_err();
1354        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1355    }
1356
1357    #[test]
1358    fn signature_tamper_rejected_by_initiator() {
1359        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1360        let (alice_hs, out1) = alice
1361            .begin_handshake_request(alice_h, alice_remote_bob)
1362            .unwrap();
1363        let req = match out1 {
1364            HandshakeStepOutcome::SendMessage { token } => token,
1365            _ => panic!(),
1366        };
1367        let (_, out2) = bob
1368            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1369            .unwrap();
1370        let mut reply = match out2 {
1371            HandshakeStepOutcome::SendMessage { token } => token,
1372            _ => panic!(),
1373        };
1374
1375        let mut h = ht::DataHolder::from_cdr_le(&reply).unwrap();
1376        let mut sig = h.binary_property("signature").unwrap().to_vec();
1377        sig[0] ^= 0x01;
1378        h.set_binary_property("signature", sig);
1379        reply = h.to_cdr_le();
1380
1381        let err = alice.process_handshake(alice_hs, &reply).unwrap_err();
1382        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1383    }
1384
1385    #[test]
1386    fn dh_tamper_in_reply_breaks_final_signature() {
1387        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1388        let (alice_hs, out1) = alice
1389            .begin_handshake_request(alice_h, alice_remote_bob)
1390            .unwrap();
1391        let req = match out1 {
1392            HandshakeStepOutcome::SendMessage { token } => token,
1393            _ => panic!(),
1394        };
1395        let (bob_hs, out2) = bob
1396            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1397            .unwrap();
1398        let mut reply = match out2 {
1399            HandshakeStepOutcome::SendMessage { token } => token,
1400            _ => panic!(),
1401        };
1402
1403        // Flips dh2 → the initiator sig verify fails (the signature
1404        // covers dh2). If this slipped through, the final
1405        // sig would diverge.
1406        let mut h = ht::DataHolder::from_cdr_le(&reply).unwrap();
1407        let mut dh2 = h.binary_property("dh2").unwrap().to_vec();
1408        dh2[0] ^= 0x01;
1409        h.set_binary_property("dh2", dh2);
1410        reply = h.to_cdr_le();
1411
1412        let err = alice.process_handshake(alice_hs, &reply).unwrap_err();
1413        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1414        let _ = bob_hs;
1415    }
1416
1417    #[test]
1418    fn wrong_ca_initiator_rejected_by_replier() {
1419        let (rogue_cert, _trusted_ca, rogue_key) = make_cert_with_wrong_ca();
1420        // Bob has the trusted CA, but Alice's cert is from a rogue CA.
1421        // We mount Bob with the trusted CA, Alice with her rogue CA.
1422
1423        // Alice needs a CA that accepts her cert — so we take
1424        // the path: Alice validate-with-config loads with the rogue CA
1425        // (the same rogue ca from the helper). But make_cert_with_wrong_ca
1426        // returns only the EE cert + the trusted CA. We would have to
1427        // rebuild the rogue CA ourselves — more pragmatic: Alice+Bob with
1428        // the same shared CA, but Bob sets his trust anchor
1429        // to a *different* CA → the initiator cert is rejected at the reply
1430        // step.
1431
1432        use rcgen::{CertificateParams, KeyPair};
1433        // Common CA for Alice's identity:
1434        let mut alice_ca_params = CertificateParams::new(vec!["AliceCA".into()]).unwrap();
1435        alice_ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1436        let alice_ca_key = KeyPair::generate().unwrap();
1437        let alice_ca_cert = alice_ca_params.self_signed(&alice_ca_key).unwrap();
1438        let alice_ca_pem = alice_ca_cert.pem();
1439
1440        let mut alice_params = CertificateParams::new(vec!["alice".into()]).unwrap();
1441        alice_params.is_ca = rcgen::IsCa::NoCa;
1442        let alice_key = KeyPair::generate().unwrap();
1443        let alice_cert = alice_params
1444            .signed_by(&alice_key, &alice_ca_cert, &alice_ca_key)
1445            .unwrap();
1446        let alice_cert_pem = alice_cert.pem().into_bytes();
1447        let alice_key_pem = alice_key.serialize_pem().into_bytes();
1448
1449        // Bob with a completely different CA (and own cert):
1450        let mut bob_ca_params = CertificateParams::new(vec!["BobCA".into()]).unwrap();
1451        bob_ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1452        let bob_ca_key = KeyPair::generate().unwrap();
1453        let bob_ca_cert = bob_ca_params.self_signed(&bob_ca_key).unwrap();
1454        let bob_ca_pem = bob_ca_cert.pem();
1455        let mut bob_params = CertificateParams::new(vec!["bob".into()]).unwrap();
1456        bob_params.is_ca = rcgen::IsCa::NoCa;
1457        let bob_key = KeyPair::generate().unwrap();
1458        let bob_cert = bob_params
1459            .signed_by(&bob_key, &bob_ca_cert, &bob_ca_key)
1460            .unwrap();
1461        let bob_cert_pem = bob_cert.pem().into_bytes();
1462        let bob_key_pem = bob_key.serialize_pem().into_bytes();
1463
1464        let mut alice = PkiAuthenticationPlugin::new();
1465        let mut bob = PkiAuthenticationPlugin::new();
1466        let alice_h = alice
1467            .validate_with_config(
1468                IdentityConfig {
1469                    identity_cert_pem: alice_cert_pem.clone(),
1470                    identity_ca_pem: alice_ca_pem.into_bytes(),
1471                    identity_key_pem: Some(alice_key_pem),
1472                },
1473                [0xAA; 16],
1474            )
1475            .unwrap();
1476        let bob_h = bob
1477            .validate_with_config(
1478                IdentityConfig {
1479                    identity_cert_pem: bob_cert_pem.clone(),
1480                    identity_ca_pem: bob_ca_pem.into_bytes(),
1481                    identity_key_pem: Some(bob_key_pem),
1482                },
1483                [0xBB; 16],
1484            )
1485            .unwrap();
1486
1487        // Alice does not know Bob (different CA), but she still tries
1488        // to start a handshake. We cannot get alice_remote_bob
1489        // via validate_remote_identity — so we fake it with
1490        // a valid stub handle. The test only checks the
1491        // replier path.
1492        let (_alice_hs, out1) = alice
1493            .begin_handshake_request(alice_h, IdentityHandle(99))
1494            .unwrap();
1495        let req = match out1 {
1496            HandshakeStepOutcome::SendMessage { token } => token,
1497            _ => panic!(),
1498        };
1499        // Bob tries to reply → Alice's cert is not from BobCA → reject.
1500        let err = bob
1501            .begin_handshake_reply(bob_h, IdentityHandle(99), &req)
1502            .unwrap_err();
1503        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1504        let _ = (rogue_cert, rogue_key);
1505    }
1506
1507    #[test]
1508    fn replay_initiator_request_rejected_second_time() {
1509        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1510        let (_alice_hs, out1) = alice
1511            .begin_handshake_request(alice_h, alice_remote_bob)
1512            .unwrap();
1513        let req = match out1 {
1514            HandshakeStepOutcome::SendMessage { token } => token,
1515            _ => panic!(),
1516        };
1517        // First reply: ok.
1518        let _ = bob
1519            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1520            .unwrap();
1521        // Second reply with the *same* request → replay → reject.
1522        let err = bob
1523            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1524            .unwrap_err();
1525        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1526    }
1527
1528    #[test]
1529    fn truncated_request_token_rejected() {
1530        let (_alice, mut bob, _alice_h, _, bob_h, bob_remote_alice) = alice_bob();
1531        let err = bob
1532            .begin_handshake_reply(bob_h, bob_remote_alice, &[0u8, 1u8, 2u8, 3u8, 4u8])
1533            .unwrap_err();
1534        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
1535    }
1536
1537    #[test]
1538    fn hash_c1_mismatch_in_request_rejected() {
1539        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1540        let (_, out1) = alice
1541            .begin_handshake_request(alice_h, alice_remote_bob)
1542            .unwrap();
1543        let req = match out1 {
1544            HandshakeStepOutcome::SendMessage { token } => token,
1545            _ => panic!(),
1546        };
1547        // Tamper with hash_c1 → parse_request_token rejected.
1548        let mut h = ht::DataHolder::from_cdr_le(&req).unwrap();
1549        let mut hc = h.binary_property("hash_c1").unwrap().to_vec();
1550        hc[5] ^= 0xFF;
1551        h.set_binary_property("hash_c1", hc);
1552        let bad = h.to_cdr_le();
1553        let err = bob
1554            .begin_handshake_reply(bob_h, bob_remote_alice, &bad)
1555            .unwrap_err();
1556        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1557    }
1558
1559    #[test]
1560    fn cross_algorithm_dsign_mismatch_rejected() {
1561        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1562        let (_alice_hs, out1) = alice
1563            .begin_handshake_request(alice_h, alice_remote_bob)
1564            .unwrap();
1565        let mut req = match out1 {
1566            HandshakeStepOutcome::SendMessage { token } => token,
1567            _ => panic!(),
1568        };
1569        // The token says "RSASSA-PSS-SHA256" although the cert is ECDSA.
1570        // But: hash_c1 contains the OLD algo → if we only swap dsign_algo,
1571        // hash_c1 will mismatch. So we must also
1572        // recompute hash_c1 and change the `c.dsign_algo`, then
1573        // the algo cross-check should fire at the replier step.
1574        let mut h = ht::DataHolder::from_cdr_le(&req).unwrap();
1575        // c.dsign_algo / c.kagree_algo are null-terminated BINARY properties
1576        // (§9.3.2.5.2.1).
1577        // NUL-free: c.dsign_algo + hash_c1 must be consistent (parse_request_token
1578        // recomputes hash_c1 over the RAW wire bytes). With a NUL in the wire value, but
1579        // without one in the hash, the hash check (AuthenticationFailed) would fire BEFORE the
1580        // algo cross-check (InvalidConfiguration). Here we want to test the algo check.
1581        h.set_binary_property("c.dsign_algo", b"RSASSA-PSS-SHA256".to_vec());
1582        let cert_der = h.binary_property("c.id").unwrap().to_vec();
1583        let perm = h.binary_property("c.perm").unwrap().to_vec();
1584        let pdata = h.binary_property("c.pdata").unwrap().to_vec();
1585        let mut kagree_bytes = h.binary_property("c.kagree_algo").unwrap().to_vec();
1586        if kagree_bytes.last() == Some(&0) {
1587            kagree_bytes.pop();
1588        }
1589        let kagree = String::from_utf8(kagree_bytes).unwrap();
1590        let new_hash = ht::compute_hash_c(&cert_der, &perm, &pdata, "RSASSA-PSS-SHA256", &kagree);
1591        h.set_binary_property("hash_c1", new_hash.to_vec());
1592        req = h.to_cdr_le();
1593        let err = bob
1594            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1595            .unwrap_err();
1596        assert_eq!(err.kind, SecurityErrorKind::InvalidConfiguration);
1597    }
1598
1599    #[test]
1600    fn extra_unknown_properties_in_reply_accepted_forward_compat() {
1601        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1602        let (alice_hs, out1) = alice
1603            .begin_handshake_request(alice_h, alice_remote_bob)
1604            .unwrap();
1605        let req = match out1 {
1606            HandshakeStepOutcome::SendMessage { token } => token,
1607            _ => panic!(),
1608        };
1609        let (_, out2) = bob
1610            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1611            .unwrap();
1612        let reply = match out2 {
1613            HandshakeStepOutcome::SendMessage { token } => token,
1614            _ => panic!(),
1615        };
1616        // Adds an unknown property.
1617        let mut h = ht::DataHolder::from_cdr_le(&reply).unwrap();
1618        h.set_property("zerodds.future.feature", "yes");
1619        h.set_binary_property("zerodds.future.opaque", alloc::vec![0xFF; 8]);
1620        // hash_c2 is over the ORIGINAL properties. Since we only add EXTRA
1621        // fields, the hash inputs stay the same → ok.
1622        let new_reply = h.to_cdr_le();
1623        let res = alice.process_handshake(alice_hs, &new_reply);
1624        assert!(res.is_ok(), "forward-compat extra props must be accepted");
1625    }
1626
1627    #[test]
1628    fn empty_permissions_accepted_in_phase3_mvp() {
1629        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1630        let (alice_hs, out1) = alice
1631            .begin_handshake_request(alice_h, alice_remote_bob)
1632            .unwrap();
1633        let req = match out1 {
1634            HandshakeStepOutcome::SendMessage { token } => token,
1635            _ => panic!(),
1636        };
1637        // alice sets no permissions in this test (no
1638        // AccessControl bind in the authentication-only roundtrip).
1639        let h = ht::DataHolder::from_cdr_le(&req).unwrap();
1640        assert_eq!(h.binary_property("c.perm").unwrap().len(), 0);
1641        let _ = bob
1642            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1643            .unwrap();
1644        let _ = alice_hs;
1645    }
1646
1647    #[test]
1648    fn shared_secret_returns_bad_argument_for_unknown_handle() {
1649        let plugin = PkiAuthenticationPlugin::new();
1650        let err = plugin.shared_secret(HandshakeHandle(42)).unwrap_err();
1651        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
1652    }
1653
1654    #[test]
1655    fn permissions_token_announces_spec_class_id_when_permissions_set() {
1656        let mut p = PkiAuthenticationPlugin::new();
1657        // Without configured permissions: no token (AccessControl
1658        // inactive) — otherwise a secure remote would reject us with an empty
1659        // permissions match.
1660        assert!(
1661            p.get_permissions_token().is_empty(),
1662            "without permissions no PermissionsToken may be announced"
1663        );
1664        // With permissions: class_id-only PermissionsToken, spec-1.0 string
1665        // (cyclone/FastDDS announce exactly `DDS:Access:Permissions:1.0`
1666        // with empty properties — we mirror that byte-structurally).
1667        p.set_local_permissions(vec![0xDE, 0xAD, 0xBE, 0xEF]);
1668        let token = p.get_permissions_token();
1669        assert!(
1670            !token.is_empty(),
1671            "PermissionsToken missing despite set permissions"
1672        );
1673        let parsed = ht::DataHolder::from_cdr_le(&token).unwrap();
1674        assert_eq!(parsed.class_id, "DDS:Access:Permissions:1.0");
1675        assert!(
1676            parsed.properties.is_empty(),
1677            "PermissionsToken announce carries no properties (cyclone mirror)"
1678        );
1679    }
1680
1681    #[test]
1682    fn validate_remote_identity_accepts_trusted_cert_der() {
1683        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1684        let mut plugin = PkiAuthenticationPlugin::new();
1685        let local = plugin
1686            .validate_with_config(
1687                IdentityConfig {
1688                    identity_cert_pem: cert_pem.clone(),
1689                    identity_ca_pem: ca_pem,
1690                    identity_key_pem: Some(key_pem),
1691                },
1692                [0xAA; 16],
1693            )
1694            .unwrap();
1695
1696        let remote_der = cert_der_from_pem(&cert_pem);
1697        let remote = plugin
1698            .validate_remote_identity(local, [0xBB; 16], &remote_der)
1699            .expect("trusted remote must be accepted");
1700        assert_ne!(remote, local);
1701    }
1702
1703    // -------------------------------------------------------------
1704    // FU2 Gap 7b — spec IdentityToken descriptor (SPDP announce)
1705    // -------------------------------------------------------------
1706
1707    #[test]
1708    fn get_identity_token_returns_decodable_spec_descriptor() {
1709        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1710        let mut plugin = PkiAuthenticationPlugin::new();
1711        let local = plugin
1712            .validate_with_config(
1713                IdentityConfig {
1714                    identity_cert_pem: cert_pem,
1715                    identity_ca_pem: ca_pem,
1716                    identity_key_pem: Some(key_pem),
1717                },
1718                [0xAA; 16],
1719            )
1720            .unwrap();
1721        let token = plugin.get_identity_token(local).expect("identity token");
1722        assert!(!token.is_empty(), "no empty default token anymore");
1723        let decoded = crate::identity_token::IdentityToken::decode(&token)
1724            .expect("must be decodable as a spec descriptor");
1725        assert!(!decoded.cert_sn.is_empty(), "cert subject in the token");
1726        assert!(!decoded.ca_sn.is_empty(), "ca subject in the token");
1727    }
1728
1729    #[test]
1730    fn validate_remote_identity_accepts_spec_descriptor_deferring_cert_check() {
1731        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1732        let mut plugin = PkiAuthenticationPlugin::new();
1733        let local = plugin
1734            .validate_with_config(
1735                IdentityConfig {
1736                    identity_cert_pem: cert_pem.clone(),
1737                    identity_ca_pem: ca_pem.clone(),
1738                    identity_key_pem: Some(key_pem),
1739                },
1740                [0xAA; 16],
1741            )
1742            .unwrap();
1743        // The peer announces a spec descriptor (no cert DER). The
1744        // cert validation happens later in the handshake (verify_remote_der
1745        // on c.id) — here the descriptor path accepts.
1746        let descriptor = crate::identity_token::build_identity_token_from_pem(&cert_pem, &ca_pem)
1747            .unwrap()
1748            .encode();
1749        let remote = plugin
1750            .validate_remote_identity(local, [0xBB; 16], &descriptor)
1751            .expect("descriptor must be accepted");
1752        assert_ne!(remote, local);
1753    }
1754
1755    #[test]
1756    fn validate_remote_identity_accepts_minimal_token_without_cert_properties() {
1757        // cyclone/FastDDS announce a MINIMAL IdentityToken in SPDP:
1758        // only class_id "DDS:Auth:PKI-DH:1.0", EMPTY properties (the cert-SN/
1759        // algo properties are optional per spec). validate_remote_identity
1760        // MUST recognize that as a spec descriptor (by class_id) and defer the
1761        // cert check to the handshake — NOT try to parse the 32 token bytes
1762        // as cert DER (that gave AuthenticationFailed
1763        // "TrailingData(SignedData)" → ZeroDDS sent no AUTH_REQUEST
1764        // cross-vendor).
1765        let (cert_pem, ca_pem, key_pem) = make_signed_cert_ca_key();
1766        let mut plugin = PkiAuthenticationPlugin::new();
1767        let local = plugin
1768            .validate_with_config(
1769                IdentityConfig {
1770                    identity_cert_pem: cert_pem,
1771                    identity_ca_pem: ca_pem,
1772                    identity_key_pem: Some(key_pem),
1773                },
1774                [0xAA; 16],
1775            )
1776            .unwrap();
1777        let minimal = zerodds_security::token::DataHolder::new(
1778            crate::identity_token::IDENTITY_TOKEN_CLASS_ID,
1779        )
1780        .to_cdr_le();
1781        let remote = plugin
1782            .validate_remote_identity(local, [0xBB; 16], &minimal)
1783            .expect("minimal cyclone token (only class_id) must be accepted");
1784        assert_ne!(remote, local);
1785    }
1786
1787    // -------------------------------------------------------------
1788    // Mutation killers (2026-05-01)
1789    // -------------------------------------------------------------
1790
1791    /// Catches mutation `>` -> `>=` and `>` -> `==` on the replay-cache
1792    /// eviction boundary. The cache MUST be able to hold EXACTLY REPLAY_CACHE_CAP
1793    /// entries — eviction only happens at the CAP+1-th.
1794    ///
1795    /// Test strategy: after EXACTLY CAP unique inserts check whether the
1796    /// first is still in the cache (= replay reject).
1797    /// * Original `>`: 1024 > 1024 false → no eviction → 0 still in → reject.
1798    /// * Mutation `==`: 1024 == 1024 true → 0 evicted → no reject.
1799    /// * Mutation `>=`: likewise evicted at 1024 → no reject.
1800    #[test]
1801    fn replay_cache_holds_exactly_cap_entries_before_eviction() {
1802        let (mut alice, _bob, alice_h, _, _, _) = alice_bob();
1803        for i in 0..REPLAY_CACHE_CAP {
1804            let mut c = [0u8; 32];
1805            c[0..8].copy_from_slice(&(i as u64).to_le_bytes());
1806            alice.record_challenge(alice_h, c).unwrap();
1807        }
1808        // Exactly CAP unique. The first (i=0) MUST still be in → replay reject.
1809        let mut c_first = [0u8; 32];
1810        c_first[0..8].copy_from_slice(&0u64.to_le_bytes());
1811        let err = alice.record_challenge(alice_h, c_first).unwrap_err();
1812        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1813    }
1814
1815    /// Eviction happens at the CAP+1-th entry — test of the
1816    /// eviction path itself (FIFO: the oldest entry is evicted).
1817    #[test]
1818    fn replay_cache_evicts_oldest_at_cap_plus_one() {
1819        let (mut alice, _bob, alice_h, _, _, _) = alice_bob();
1820        for i in 0..REPLAY_CACHE_CAP {
1821            let mut c = [0u8; 32];
1822            c[0..8].copy_from_slice(&(i as u64).to_le_bytes());
1823            alice.record_challenge(alice_h, c).unwrap();
1824        }
1825        // CAP+1: eviction triggers on the oldest (i=0).
1826        let mut c_extra = [0u8; 32];
1827        c_extra[0..8].copy_from_slice(&(REPLAY_CACHE_CAP as u64).to_le_bytes());
1828        alice.record_challenge(alice_h, c_extra).unwrap();
1829
1830        // The first (i=0) should now be evicted → re-insert succeeds.
1831        let mut c_first = [0u8; 32];
1832        c_first[0..8].copy_from_slice(&0u64.to_le_bytes());
1833        alice
1834            .record_challenge(alice_h, c_first)
1835            .expect("after CAP+1 inserts, oldest must be evicted");
1836
1837        // A young entry (e.g. the CAP-th) must still be in.
1838        // After inserting 0, 1 was evicted (FIFO), so we test
1839        // an entry that is surely still present — the CAP-th (=1024).
1840        let mut c_recent = [0u8; 32];
1841        c_recent[0..8].copy_from_slice(&(REPLAY_CACHE_CAP as u64).to_le_bytes());
1842        let err = alice.record_challenge(alice_h, c_recent).unwrap_err();
1843        assert_eq!(err.kind, SecurityErrorKind::AuthenticationFailed);
1844    }
1845
1846    /// Catches mutation `get_shared_secret -> None / Some(vec![]) / Some(vec![0]) / Some(vec![1])`.
1847    /// After a successful handshake `get_shared_secret` must return the
1848    /// stored 32-byte secret, not None or a
1849    /// blanket value.
1850    #[test]
1851    fn get_shared_secret_returns_stored_value() {
1852        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1853
1854        let (alice_hs, out1) = alice
1855            .begin_handshake_request(alice_h, alice_remote_bob)
1856            .unwrap();
1857        let req = match out1 {
1858            HandshakeStepOutcome::SendMessage { token } => token,
1859            _ => panic!(),
1860        };
1861        let (bob_hs, out2) = bob
1862            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1863            .unwrap();
1864        let reply = match out2 {
1865            HandshakeStepOutcome::SendMessage { token } => token,
1866            _ => panic!(),
1867        };
1868        let out3 = alice.process_handshake(alice_hs, &reply).unwrap();
1869        let final_token = match out3 {
1870            HandshakeStepOutcome::SendMessage { token } => token,
1871            _ => panic!(),
1872        };
1873        let out4 = bob.process_handshake(bob_hs, &final_token).unwrap();
1874        let bob_handle = match out4 {
1875            HandshakeStepOutcome::Complete { secret } => secret,
1876            _ => panic!(),
1877        };
1878        let alice_handle = alice.shared_secret(alice_hs).unwrap();
1879
1880        // get_shared_secret must return concrete 32 bytes:
1881        let alice_bytes = SharedSecretProvider::get_shared_secret(&alice, alice_handle).unwrap();
1882        let bob_bytes = SharedSecretProvider::get_shared_secret(&bob, bob_handle).unwrap();
1883        // Mutation `None`: unwrap panicked → test fails.
1884        // Mutation `Some(vec![])`: len=0 != 32.
1885        // Mutation `Some(vec![0])` / `Some(vec![1])`: len=1 != 32.
1886        assert_eq!(alice_bytes.len(), 32);
1887        assert_eq!(bob_bytes.len(), 32);
1888        assert_eq!(
1889            alice_bytes, bob_bytes,
1890            "shared secrets must be identical + non-trivial"
1891        );
1892        assert!(alice_bytes.iter().any(|&b| b != 0));
1893        assert!(alice_bytes.iter().any(|&b| b != 1));
1894
1895        // get_shared_secret with an unknown handle must return None.
1896        let bogus_handle = zerodds_security::authentication::SharedSecretHandle(0xDEAD_BEEF);
1897        assert!(SharedSecretProvider::get_shared_secret(&alice, bogus_handle).is_none());
1898    }
1899
1900    /// Catches mutations `||` -> `&&` on each of the 6 echo checks in
1901    /// `process_final_on_replier`. One test per field — if ONE
1902    /// field is wrong, bob must reject the final token.
1903    /// With `&&`: only ALL 6 mismatches would reject.
1904    fn run_handshake_tampered_final<M>(mutate: M)
1905    where
1906        M: FnOnce(&mut ht::DataHolder),
1907    {
1908        let (mut alice, mut bob, alice_h, alice_remote_bob, bob_h, bob_remote_alice) = alice_bob();
1909        let (alice_hs, out1) = alice
1910            .begin_handshake_request(alice_h, alice_remote_bob)
1911            .unwrap();
1912        let req = match out1 {
1913            HandshakeStepOutcome::SendMessage { token } => token,
1914            _ => panic!(),
1915        };
1916        let (bob_hs, out2) = bob
1917            .begin_handshake_reply(bob_h, bob_remote_alice, &req)
1918            .unwrap();
1919        let reply = match out2 {
1920            HandshakeStepOutcome::SendMessage { token } => token,
1921            _ => panic!(),
1922        };
1923        let out3 = alice.process_handshake(alice_hs, &reply).unwrap();
1924        let final_token = match out3 {
1925            HandshakeStepOutcome::SendMessage { token } => token,
1926            _ => panic!(),
1927        };
1928
1929        let mut h = ht::DataHolder::from_cdr_le(&final_token).unwrap();
1930        mutate(&mut h);
1931        let tampered = h.to_cdr_le();
1932        let err = bob.process_handshake(bob_hs, &tampered).unwrap_err();
1933        assert_eq!(
1934            err.kind,
1935            SecurityErrorKind::AuthenticationFailed,
1936            "tampered final token must return AuthFailed"
1937        );
1938    }
1939
1940    fn flip_first_byte(h: &mut ht::DataHolder, prop: &str) {
1941        let mut v = h.binary_property(prop).unwrap().to_vec();
1942        v[0] ^= 0x01;
1943        h.set_binary_property(prop, v);
1944    }
1945
1946    #[test]
1947    fn final_token_hash_c1_tamper_rejected() {
1948        run_handshake_tampered_final(|h| flip_first_byte(h, "hash_c1"));
1949    }
1950    #[test]
1951    fn final_token_hash_c2_tamper_rejected() {
1952        run_handshake_tampered_final(|h| flip_first_byte(h, "hash_c2"));
1953    }
1954    #[test]
1955    fn final_token_dh1_tamper_rejected() {
1956        run_handshake_tampered_final(|h| flip_first_byte(h, "dh1"));
1957    }
1958    #[test]
1959    fn final_token_dh2_tamper_rejected() {
1960        run_handshake_tampered_final(|h| flip_first_byte(h, "dh2"));
1961    }
1962    #[test]
1963    fn final_token_challenge1_tamper_rejected() {
1964        run_handshake_tampered_final(|h| flip_first_byte(h, "challenge1"));
1965    }
1966    #[test]
1967    fn final_token_challenge2_tamper_rejected() {
1968        run_handshake_tampered_final(|h| flip_first_byte(h, "challenge2"));
1969    }
1970}