Skip to main content

zerodds_security_runtime/
profile.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 profile builder emits the config-driven chosen
6// DDS-Security plugins as `dyn ...Plugin` — inherent runtime
7// polymorphism of the plugin pattern, not replaceable by concrete generics.
8//! Vendor-style-conform "from-paths" builder for DDS-Security 1.2 setups.
9//!
10//! Bundles the building blocks:
11//! - [`zerodds_security_pki::PkiAuthenticationPlugin`] with identity cert+CA+key
12//! - [`zerodds_security_permissions::CmsPkcs7Verifier`] on the permissions CA
13//! - `governance.p7s` + `permissions.p7s` verified + parsed via CMS
14//! - [`zerodds_security_crypto::AesGcmCryptoPlugin`] + [`crate::SharedSecurityGate`]
15//!
16//! This gives FFI callers (zerodds-c-api) and bench apps a
17//! vendor-equivalent API: give me 6 PEM/PKCS#7 paths, I deliver
18//! a ready-to-consume [`SecurityProfile`] whose `gate` can be attached
19//! to `RuntimeConfig.security` in [`crate::SharedSecurityGate`] form.
20
21// `cfg(feature = "std")` already lives on the `mod profile` statement in lib.rs;
22// do not attribute it additionally here (clippy::duplicated_attributes).
23
24use alloc::boxed::Box;
25use alloc::string::{String, ToString};
26use alloc::sync::Arc;
27use alloc::vec::Vec;
28use std::path::{Path, PathBuf};
29use std::sync::Mutex;
30
31use zerodds_security::authentication::{SharedSecretHandle, SharedSecretProvider};
32use zerodds_security::error::SecurityError;
33use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
34use zerodds_security_permissions::{
35    CmsPkcs7Verifier, Governance, Permissions, PermissionsError, XmlSignatureVerifier,
36    open_signed_permissions, parse_governance_xml,
37};
38use zerodds_security_pki::{IdentityConfig, IdentityHandle, PkiAuthenticationPlugin, PkiError};
39
40use crate::SharedSecurityGate;
41
42/// Shares the `Arc<Mutex<PkiAuthenticationPlugin>>` as a
43/// [`SharedSecretProvider`] with the crypto plugin. The crypto plugin
44/// calls `get_shared_secret` only on `register_matched_remote_participant`
45/// — the handshake driver MUST release the PKI lock beforehand (otherwise
46/// a deadlock, since `get_shared_secret` takes the same mutex).
47struct SharedPkiSecretProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
48
49impl SharedSecretProvider for SharedPkiSecretProvider {
50    fn get_shared_secret(&self, handle: SharedSecretHandle) -> Option<Vec<u8>> {
51        self.0.lock().ok()?.get_shared_secret(handle)
52    }
53
54    fn get_shared_secret_challenges(
55        &self,
56        handle: SharedSecretHandle,
57    ) -> Option<([u8; 32], [u8; 32])> {
58        // MUST be forwarded — otherwise the default `None` applies and the
59        // crypto plugin falls back to the proprietary from_shared_secret_kx
60        // instead of the cyclone-exact VolatileSecure key derivation (§9.5.3.5).
61        self.0.lock().ok()?.get_shared_secret_challenges(handle)
62    }
63}
64
65/// Error paths when building a [`SecurityProfile`] from files.
66#[derive(Debug)]
67pub enum SecurityProfileError {
68    /// `std::fs::read` failed on `path`.
69    Io {
70        /// Path at which the I/O attempt failed.
71        path: PathBuf,
72        /// Original error.
73        source: std::io::Error,
74    },
75    /// The PKI layer rejected the cert/key bundle (chain, algo, format).
76    Pki(PkiError),
77    /// The PKI plugin (or a sub-plugin underneath) rejected the
78    /// handshake/identity setup path with a [`SecurityError`]
79    /// — typically a wrapper around [`PkiError`].
80    PkiSecurity(SecurityError),
81    /// The permissions/governance layer rejected the XML/CMS.
82    Permissions(PermissionsError),
83    /// The verified governance XML was not valid UTF-8.
84    GovernanceUtf8(core::str::Utf8Error),
85}
86
87impl core::fmt::Display for SecurityProfileError {
88    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
89        match self {
90            Self::Io { path, source } => {
91                write!(f, "security-profile io {}: {source}", path.display())
92            }
93            Self::Pki(e) => write!(f, "security-profile pki: {e}"),
94            Self::PkiSecurity(e) => write!(f, "security-profile pki: {e}"),
95            Self::Permissions(e) => write!(f, "security-profile permissions: {e}"),
96            Self::GovernanceUtf8(e) => write!(f, "security-profile governance utf-8: {e}"),
97        }
98    }
99}
100
101impl std::error::Error for SecurityProfileError {}
102
103impl From<PkiError> for SecurityProfileError {
104    fn from(e: PkiError) -> Self {
105        Self::Pki(e)
106    }
107}
108impl From<SecurityError> for SecurityProfileError {
109    fn from(e: SecurityError) -> Self {
110        Self::PkiSecurity(e)
111    }
112}
113impl From<PermissionsError> for SecurityProfileError {
114    fn from(e: PermissionsError) -> Self {
115        Self::Permissions(e)
116    }
117}
118
119/// File-path-based configuration for a [`SecurityProfile`].
120///
121/// All paths must be readable and contain the usual
122/// DDS-Security 1.2 file formats:
123/// - `identity_ca_pem` — PEM bundle of the identity CA
124/// - `identity_cert_pem` — PEM with the participant's identity certificate
125/// - `identity_key_pem` — PKCS#8 PEM with the private key
126/// - `permissions_ca_pem` — PEM bundle of the permissions CA (often = identity_ca)
127/// - `governance_p7s` — CMS-signed governance XML (`.p7s`)
128/// - `permissions_p7s` — CMS-signed permissions XML (`.p7s`)
129#[derive(Debug, Clone)]
130pub struct SecurityProfileConfig {
131    /// DDS domain id (decides the match in the governance).
132    pub domain_id: u32,
133    /// PEM bundle of the identity CA (trust anchors for remote certs).
134    pub identity_ca_pem: PathBuf,
135    /// PEM with the identity cert of the local participant.
136    pub identity_cert_pem: PathBuf,
137    /// PKCS#8 PEM private key of the local participant.
138    pub identity_key_pem: PathBuf,
139    /// PEM bundle of the permissions CA (signs governance/permissions).
140    pub permissions_ca_pem: PathBuf,
141    /// CMS-signed governance XML.
142    pub governance_p7s: PathBuf,
143    /// CMS-signed permissions XML.
144    pub permissions_p7s: PathBuf,
145}
146
147/// Fully built security profile. The caller typically only needs
148/// `gate` (attach to `RuntimeConfig.security`) — `pki`/`identity_handle`
149/// are needed for later programmatic handshake driving
150/// (e.g. for tests that work without SEDP).
151pub struct SecurityProfile {
152    /// Ready-to-consume [`SharedSecurityGate`] in `Arc` form, as
153    /// `RuntimeConfig.security` expects it.
154    pub gate: Arc<SharedSecurityGate>,
155    /// PKI plugin with a registered local identity. `Arc<Mutex>`,
156    /// because it is shared by both the handshake driver (`&mut` for
157    /// begin/process_handshake) and the crypto plugin (as a
158    /// [`SharedSecretProvider`], `&self`).
159    pub pki: Arc<Mutex<PkiAuthenticationPlugin>>,
160    /// Handle of the local participant in the PKI plugin.
161    pub identity_handle: IdentityHandle,
162    /// The DDS-Security §9.3.3-adjusted 16-byte participant GUID (prefix
163    /// cryptographically bound to the identity). The caller MUST use this GUID
164    /// (or its prefix) for the runtime/SPDP participant, so that
165    /// the SPDP beacon, handshake `c.pdata` and all entity GUIDs are consistent.
166    pub adjusted_participant_guid: [u8; 16],
167    /// Parsed governance.
168    pub governance: Governance,
169    /// Parsed permissions.
170    pub permissions: Permissions,
171}
172
173impl core::fmt::Debug for SecurityProfile {
174    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
175        // PkiAuthenticationPlugin holds cert/key/secrets — never
176        // debug-print. Gate debug is OK (shows only metadata).
177        f.debug_struct("SecurityProfile")
178            .field("gate", &self.gate)
179            .field("identity_handle", &self.identity_handle)
180            .field("governance", &self.governance)
181            .field("permissions", &"<redacted>")
182            .field("pki", &"<redacted PkiAuthenticationPlugin>")
183            .finish()
184    }
185}
186
187impl SecurityProfile {
188    /// Reads all files, verifies CMS signatures, builds PKI + gate.
189    ///
190    /// `participant_guid` is the 16-byte DDS GUID of the local
191    /// participant — embedded by the PKI plugin into the handshake
192    /// token.
193    ///
194    /// # Errors
195    /// [`SecurityProfileError`] in the variants Io / Pki / Permissions
196    /// / GovernanceUtf8.
197    pub fn from_files(
198        cfg: &SecurityProfileConfig,
199        participant_guid: [u8; 16],
200    ) -> Result<Self, SecurityProfileError> {
201        let identity_cert_pem = read_path(&cfg.identity_cert_pem)?;
202        let identity_ca_pem = read_path(&cfg.identity_ca_pem)?;
203        let identity_key_pem = read_path(&cfg.identity_key_pem)?;
204        let permissions_ca_pem = read_path(&cfg.permissions_ca_pem)?;
205        let governance_p7s = read_path(&cfg.governance_p7s)?;
206        let permissions_p7s = read_path(&cfg.permissions_p7s)?;
207
208        // DDS-Security §9.3.3: bind the participant GUID prefix
209        // cryptographically to the identity. Cyclone/FastDDS check in the handshake
210        // (begin_handshake_reply) that the c.pdata GUID is derived from the
211        // identity — a random GUID is rejected with "c.pdata contains
212        // incorrect participant guid".
213        let cert_der = zerodds_security_pki::first_cert_der(&identity_cert_pem)?;
214        let adjusted_prefix =
215            zerodds_security_pki::adjust_participant_guid_prefix(&participant_guid, &cert_der)?;
216        let mut adjusted_participant_guid = participant_guid;
217        adjusted_participant_guid[..12].copy_from_slice(&adjusted_prefix);
218
219        // 1. PKI: validate the identity (chain against identity_ca, algo whitelist)
220        //    — bound to the ADJUSTED GUID that the participant actually
221        //    announces + carries as source_guid/c.pdata in the handshake.
222        let mut pki = PkiAuthenticationPlugin::new();
223        let identity_handle = pki.validate_with_config(
224            IdentityConfig {
225                identity_cert_pem,
226                identity_ca_pem,
227                identity_key_pem: Some(identity_key_pem),
228            },
229            adjusted_participant_guid,
230        )?;
231
232        // 2. CMS verifier on the permissions CA — the same verifier
233        //    is used for governance.p7s AND permissions.p7s
234        //    (both are typically signed by the same authority).
235        let verifier = CmsPkcs7Verifier::new(&permissions_ca_pem)?;
236
237        // 3. Extract + parse the governance XML from the CMS wrapper.
238        let governance_xml_bytes = verifier.verify_and_extract(&governance_p7s)?;
239        let governance_xml = core::str::from_utf8(&governance_xml_bytes)
240            .map_err(SecurityProfileError::GovernanceUtf8)?;
241        let governance = parse_governance_xml(governance_xml)?;
242
243        // 4. Permissions analogously via `open_signed_permissions`.
244        let permissions = open_signed_permissions(&permissions_p7s, &verifier)?;
245
246        // 4b. Give the CMS-signed permissions document to the PKI plugin —
247        //     it is sent along as `c.perm` in the auth handshake. Foreign vendors
248        //     with active access control (governance) validate this signature
249        //     against the permissions_ca; without `c.perm` they reject the
250        //     handshake (cross-vendor NO_MATCH).
251        pki.set_local_permissions(permissions_p7s.clone());
252
253        // 5. Build the gate with AES-GCM crypto + parsed governance.
254        //    The crypto plugin gets the PKI plugin as a
255        //    SharedSecretProvider: after the auth handshake completes,
256        //    `register_matched_remote_participant` derives the per-peer
257        //    master key deterministically from the DH shared secret (instead of
258        //    random + token exchange). Without a completed handshake
259        //    (e.g. the ZeroDDS-self path without enable_security_builtins)
260        //    the provider returns None and the crypto plugin falls back to
261        //    the v1.4 random-key path — backward compat preserved.
262        let pki = Arc::new(Mutex::new(pki));
263        let provider: Arc<dyn SharedSecretProvider> =
264            Arc::new(SharedPkiSecretProvider(Arc::clone(&pki)));
265        // Per-scope suites from the governance: *_protection_kind=SIGN -> AES-256-
266        // GMAC (auth-only, cyclone-conform), otherwise AES-256-GCM. participant_suite
267        // <- rtps_protection (SRTPS message key); endpoint_suite <- data_protection
268        // (payload/submessage key). The Kx key stays independently GCM.
269        let sign_suite = |k: zerodds_security_permissions::ProtectionKind| {
270            if matches!(k, zerodds_security_permissions::ProtectionKind::Sign) {
271                Suite::Aes256Gmac
272            } else {
273                Suite::Aes256Gcm
274            }
275        };
276        let domain_rule = governance.find_domain_rule(cfg.domain_id);
277        let rtps_kind = domain_rule
278            .map(|r| r.rtps_protection_kind)
279            .unwrap_or_default();
280        let data_kind = domain_rule
281            .and_then(|r| r.topic_rules.first())
282            .map(|t| t.data_protection_kind)
283            .unwrap_or_default();
284        let metadata_kind = domain_rule
285            .and_then(|r| r.topic_rules.first())
286            .map(|t| t.metadata_protection_kind)
287            .unwrap_or_default();
288        let mut crypto = AesGcmCryptoPlugin::with_secret_provider(Suite::Aes256Gcm, provider);
289        // Set metadata_suite only when metadata protection is active; then
290        // the submessage key gets the metadata kind and (if != data kind)
291        // register_local_endpoint switches to the dual-key model (meta-sign-data).
292        let metadata_suite = if matches!(
293            metadata_kind,
294            zerodds_security_permissions::ProtectionKind::None
295        ) {
296            None
297        } else {
298            Some(sign_suite(metadata_kind))
299        };
300        crypto.set_local_protection_suites(
301            Some(sign_suite(rtps_kind)),
302            Some(sign_suite(data_kind)),
303            metadata_suite,
304        );
305        let gate = Arc::new(SharedSecurityGate::new(
306            cfg.domain_id,
307            governance.clone(),
308            Box::new(crypto),
309        ));
310
311        Ok(Self {
312            gate,
313            pki,
314            identity_handle,
315            adjusted_participant_guid,
316            governance,
317            permissions,
318        })
319    }
320
321    /// C7 — loads a profile from an **SROS2 enclave directory** in one call.
322    ///
323    /// An SROS2 keystore lays each participant's material out under
324    /// `enclaves/<name>/` and symlinks the standard file names into it:
325    ///
326    /// | enclave file              | role                          |
327    /// |---------------------------|-------------------------------|
328    /// | `cert.pem`                | identity certificate          |
329    /// | `key.pem`                 | identity private key (PKCS#8) |
330    /// | `identity_ca.cert.pem`    | identity CA bundle            |
331    /// | `permissions_ca.cert.pem` | permissions CA bundle         |
332    /// | `governance.p7s`          | CMS-signed governance XML     |
333    /// | `permissions.p7s`         | CMS-signed permissions XML    |
334    ///
335    /// This replaces the six-path [`SecurityProfileConfig`] ceremony with a
336    /// single directory, mirroring how `ros2 security` enclaves are consumed.
337    ///
338    /// # Errors
339    /// [`SecurityProfileError::Io`] when a standard file is absent, plus the
340    /// Pki/Permissions/Governance variants from [`Self::from_files`].
341    pub fn from_enclave_dir(
342        enclave_dir: impl AsRef<Path>,
343        domain_id: u32,
344        participant_guid: [u8; 16],
345    ) -> Result<Self, SecurityProfileError> {
346        let dir = enclave_dir.as_ref();
347        let cfg = SecurityProfileConfig {
348            domain_id,
349            identity_ca_pem: dir.join("identity_ca.cert.pem"),
350            identity_cert_pem: dir.join("cert.pem"),
351            identity_key_pem: dir.join("key.pem"),
352            permissions_ca_pem: dir.join("permissions_ca.cert.pem"),
353            governance_p7s: dir.join("governance.p7s"),
354            permissions_p7s: dir.join("permissions.p7s"),
355        };
356        Self::from_files(&cfg, participant_guid)
357    }
358
359    /// C7 — "secure by default" env entry point. Loads an enclave from
360    /// `ZERODDS_SECURITY_DIR`; the domain comes from `ROS_DOMAIN_ID`
361    /// (default 0). Returns `Ok(None)` when `ZERODDS_SECURITY_DIR` is unset,
362    /// so a launch path can opt into security with a single env var:
363    ///
364    /// ```text
365    /// export ZERODDS_SECURITY_DIR=$ROS_SECURITY_KEYSTORE/enclaves/talker
366    /// export ROS_DOMAIN_ID=42
367    /// ```
368    ///
369    /// # Errors
370    /// Propagates [`Self::from_enclave_dir`] errors when the dir is set but
371    /// the material is missing or invalid.
372    pub fn from_env(participant_guid: [u8; 16]) -> Result<Option<Self>, SecurityProfileError> {
373        let dir = match std::env::var("ZERODDS_SECURITY_DIR") {
374            Ok(d) if !d.is_empty() => d,
375            _ => return Ok(None),
376        };
377        let domain_id = std::env::var("ROS_DOMAIN_ID")
378            .ok()
379            .and_then(|s| s.trim().parse::<u32>().ok())
380            .unwrap_or(0);
381        Self::from_enclave_dir(dir, domain_id, participant_guid).map(Some)
382    }
383}
384
385fn read_path(p: &Path) -> Result<Vec<u8>, SecurityProfileError> {
386    std::fs::read(p).map_err(|source| SecurityProfileError::Io {
387        path: p.to_path_buf(),
388        source,
389    })
390}
391
392/// Small helper renderer that extracts a path property from a
393/// `file:///abs/path` or plain-path string. The vendor
394/// property strings are typically `file:///etc/dds/certs/...`,
395/// partly also bare — both forms are swallowed.
396#[must_use]
397pub fn strip_file_url(s: &str) -> String {
398    s.strip_prefix("file://")
399        .map(|rest| rest.trim_start_matches('/').to_string())
400        .map(|rest| {
401            // `file:///etc/...` → `/etc/...`; `file://etc/...` (rare) → `etc/...`
402            if s.starts_with("file:///") {
403                format!("/{rest}")
404            } else {
405                rest
406            }
407        })
408        .unwrap_or_else(|| s.to_string())
409}
410
411#[cfg(test)]
412#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn strip_file_url_handles_triple_slash() {
418        assert_eq!(
419            strip_file_url("file:///etc/dds/certs/ca.pem"),
420            "/etc/dds/certs/ca.pem"
421        );
422    }
423
424    #[test]
425    fn strip_file_url_handles_double_slash() {
426        assert_eq!(
427            strip_file_url("file://relative/path.pem"),
428            "relative/path.pem"
429        );
430    }
431
432    #[test]
433    fn strip_file_url_passes_plain_path() {
434        assert_eq!(strip_file_url("/tmp/whatever.pem"), "/tmp/whatever.pem");
435    }
436
437    #[test]
438    fn missing_file_returns_io_error() {
439        let cfg = SecurityProfileConfig {
440            domain_id: 0,
441            identity_ca_pem: PathBuf::from("/zerodds/_does_not_exist_ca.pem"),
442            identity_cert_pem: PathBuf::from("/zerodds/_does_not_exist_cert.pem"),
443            identity_key_pem: PathBuf::from("/zerodds/_does_not_exist_key.pem"),
444            permissions_ca_pem: PathBuf::from("/zerodds/_does_not_exist_pca.pem"),
445            governance_p7s: PathBuf::from("/zerodds/_does_not_exist_gov.p7s"),
446            permissions_p7s: PathBuf::from("/zerodds/_does_not_exist_perm.p7s"),
447        };
448        match SecurityProfile::from_files(&cfg, [0u8; 16]) {
449            Err(SecurityProfileError::Io { .. }) => {}
450            Err(e) => panic!("expected Io error, got: {e:?}"),
451            Ok(_) => panic!("expected Err, got Ok"),
452        }
453    }
454
455    /// Unique scratch directory under the system temp dir (no external
456    /// `tempfile` dep; cleaned by the caller).
457    fn scratch_dir(tag: &str) -> PathBuf {
458        use std::sync::atomic::{AtomicU32, Ordering};
459        static N: AtomicU32 = AtomicU32::new(0);
460        let dir = std::env::temp_dir().join(format!(
461            "zerodds_enclave_{tag}_{}_{}",
462            std::process::id(),
463            N.fetch_add(1, Ordering::Relaxed)
464        ));
465        std::fs::create_dir_all(&dir).expect("mkdir scratch");
466        dir
467    }
468
469    #[test]
470    fn enclave_dir_resolves_all_sros2_filenames() {
471        // C7: from_enclave_dir must map the six SROS2 enclave file names. With
472        // all six present (dummy content) it gets PAST file resolution and
473        // fails later at PKI parsing — i.e. NO Io error → the mapping is
474        // correct end-to-end.
475        let dir = scratch_dir("all");
476        for f in [
477            "cert.pem",
478            "key.pem",
479            "identity_ca.cert.pem",
480            "permissions_ca.cert.pem",
481            "governance.p7s",
482            "permissions.p7s",
483        ] {
484            std::fs::write(dir.join(f), b"dummy").expect("write");
485        }
486        let res = SecurityProfile::from_enclave_dir(&dir, 0, [0u8; 16]);
487        std::fs::remove_dir_all(&dir).ok();
488        match res {
489            Err(SecurityProfileError::Io { path, .. }) => {
490                panic!(
491                    "filename mapping wrong — unexpected Io on {}",
492                    path.display()
493                )
494            }
495            Err(_) => {} // expected: PKI/parse error on the dummy cert
496            Ok(_) => panic!("dummy content must not build a valid profile"),
497        }
498    }
499
500    #[test]
501    fn enclave_dir_missing_cert_is_io_naming_cert() {
502        // An empty enclave dir → the first read (cert.pem) fails with an Io
503        // error whose path is exactly the SROS2 cert name.
504        let dir = scratch_dir("nocert");
505        let res = SecurityProfile::from_enclave_dir(&dir, 0, [0u8; 16]);
506        std::fs::remove_dir_all(&dir).ok();
507        match res {
508            Err(SecurityProfileError::Io { path, .. }) => {
509                assert!(
510                    path.ends_with("cert.pem"),
511                    "Io path should be the enclave cert.pem, got {}",
512                    path.display()
513                );
514            }
515            other => panic!("expected Io on cert.pem, got {other:?}"),
516        }
517    }
518
519    #[test]
520    fn from_env_unset_returns_none() {
521        // Skip if the var happens to be set in this environment.
522        if std::env::var("ZERODDS_SECURITY_DIR").is_ok() {
523            return;
524        }
525        match SecurityProfile::from_env([0u8; 16]) {
526            Ok(None) => {}
527            other => panic!("expected Ok(None) when ZERODDS_SECURITY_DIR unset, got {other:?}"),
528        }
529    }
530}