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}