Skip to main content

zerodds_security_rtps/
srtps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Message-level protection: `SRTPS_PREFIX` + `SRTPS_POSTFIX`.
5//!
6//! Spec §7.3.7: wraps a **whole** RTPS message (with embedded
7//! submessages) into an outer protection layer. Used for
8//! `rtps_protection_kind=ENCRYPT` in the governance XML.
9//!
10//! zerodds-lint: allow no_dyn_in_safe
11//! (Like `codec.rs`, the wrapper takes `&dyn CryptographicPlugin`.)
12//!
13//! ```text
14//! [ RTPS header (20 bytes, plaintext) ]
15//! [ SRTPS_PREFIX (4+16 bytes) ]
16//! [ <encrypted body> ... ]
17//! [ SRTPS_POSTFIX (4+0 bytes, empty MAC list in single-receiver mode) ]
18//! ```
19//!
20//! The RTPS header (first 20 bytes) stays **plaintext**, so that
21//! receivers can see the magic "RTPS" + version + VendorId + prefix
22//! without decrypting first. Everything after it is encrypted +
23//! authenticated via AES-GCM.
24
25use alloc::vec::Vec;
26
27use zerodds_security::crypto::{CryptoHandle, CryptographicPlugin};
28
29use crate::codec::{SRTPS_POSTFIX, SRTPS_PREFIX, SecurityRtpsError};
30
31/// RTPS header size (spec §8.3.3.1).
32pub const RTPS_HEADER_LEN: usize = 20;
33
34const FLAG_LE: u8 = 0x01;
35
36/// `PreSharedKeyFlag` im SRTPS_PREFIX-Submessage-Header — Spec
37/// DDS-Security 1.2 §10.9.1.
38///
39/// When set, the sender signals that the master keys are derived from
40/// a **pre-shared key** (DDS:Crypto:PSK:AES-GCM-
41/// GMAC:1.2) instead of from an X.509 DH handshake. Decoders may observe the
42/// bit to select the correct crypto plugin — on
43/// the wire the body layout (TransformIdentifier, body, MACs)
44/// stays identical, only the key origin differs.
45///
46/// Bit position 0x02 (bit 1) — next to `FLAG_LE` (bit 0). Other
47/// submessage flags (bits 2..7) are reserved.
48pub const PRE_SHARED_KEY_FLAG: u8 = 0x02;
49
50fn push_header(out: &mut Vec<u8>, id: u8, length: u16) {
51    out.push(id);
52    out.push(FLAG_LE);
53    out.extend_from_slice(&length.to_le_bytes());
54}
55
56fn push_header_with_flags(out: &mut Vec<u8>, id: u8, flags: u8, length: u16) {
57    out.push(id);
58    out.push(flags);
59    out.extend_from_slice(&length.to_le_bytes());
60}
61
62/// Like [`encode_secured_rtps_message`], but additionally sets the
63/// `PreSharedKeyFlag` in the SRTPS_PREFIX (spec §10.9.1) — for the
64/// PSK crypto path.
65///
66/// # Errors
67/// Like [`encode_secured_rtps_message`].
68pub fn encode_secured_rtps_message_psk(
69    plugin: &dyn CryptographicPlugin,
70    local: CryptoHandle,
71    remote_list: &[CryptoHandle],
72    message: &[u8],
73) -> Result<Vec<u8>, SecurityRtpsError> {
74    if message.len() < RTPS_HEADER_LEN {
75        return Err(SecurityRtpsError::Truncated("rtps-message header"));
76    }
77    let (header, body) = message.split_at(RTPS_HEADER_LEN);
78
79    // AAD extension per DDS-Security 1.2 §7.4.6.6 (RTPS message
80    // protection): reserved-4 || RTPS-Header[0..20]. Protects the
81    // header against tampering — the plugin's `mat.aad(extension)`
82    // additionally prepends `transformation_kind || key_id || session_id`.
83    let mut aad_extension = Vec::with_capacity(4 + RTPS_HEADER_LEN);
84    aad_extension.extend_from_slice(&[0u8; 4]);
85    aad_extension.extend_from_slice(header);
86
87    let ciphertext = plugin
88        .encrypt_submessage(local, remote_list, body, &aad_extension)
89        .map_err(SecurityRtpsError::Crypto)?;
90
91    let body_len = u16::try_from(ciphertext.len())
92        .map_err(|_| SecurityRtpsError::Truncated("SRTPS body > u16"))?;
93
94    let mut out = Vec::with_capacity(RTPS_HEADER_LEN + 4 + 16 + 4 + ciphertext.len() + 4);
95    out.extend_from_slice(header);
96
97    // SRTPS_PREFIX with PSK flag.
98    push_header_with_flags(&mut out, SRTPS_PREFIX, FLAG_LE | PRE_SHARED_KEY_FLAG, 16);
99    out.extend_from_slice(&[0u8; 16]);
100
101    push_header(&mut out, crate::codec::SEC_BODY, body_len);
102    out.extend_from_slice(&ciphertext);
103
104    push_header(&mut out, SRTPS_POSTFIX, 0);
105
106    Ok(out)
107}
108
109/// Reads the `PreSharedKeyFlag` bit from the SRTPS_PREFIX of a
110/// secured RTPS message. Returns `None` if the wire is not a valid
111/// SRTPS wrapping.
112#[must_use]
113pub fn srtps_psk_flag(wire: &[u8]) -> Option<bool> {
114    if wire.len() < RTPS_HEADER_LEN + 4 {
115        return None;
116    }
117    if wire[RTPS_HEADER_LEN] != SRTPS_PREFIX {
118        return None;
119    }
120    Some(wire[RTPS_HEADER_LEN + 1] & PRE_SHARED_KEY_FLAG != 0)
121}
122
123/// Protects a **whole** RTPS message. The first 20 bytes (header)
124/// stay plaintext; everything after it (the submessage stream) is
125/// encrypted + authenticated. Output:
126///
127/// ```text
128/// [ header (20) | SRTPS_PREFIX | encrypted body | SRTPS_POSTFIX ]
129/// ```
130///
131/// # Errors
132/// Input too short for the header, or a crypto-plugin error.
133pub fn encode_secured_rtps_message(
134    plugin: &dyn CryptographicPlugin,
135    local: CryptoHandle,
136    remote_list: &[CryptoHandle],
137    message: &[u8],
138) -> Result<Vec<u8>, SecurityRtpsError> {
139    if message.len() < RTPS_HEADER_LEN {
140        return Err(SecurityRtpsError::Truncated("rtps-message header"));
141    }
142    let (header, body) = message.split_at(RTPS_HEADER_LEN);
143
144    // AAD extension per DDS-Security 1.2 §7.4.6.6 (RTPS message
145    // protection): reserved-4 || RTPS-Header[0..20]. Protects the
146    // header against tampering — the plugin's `mat.aad(extension)`
147    // additionally prepends `transformation_kind || key_id || session_id`.
148    let mut aad_extension = Vec::with_capacity(4 + RTPS_HEADER_LEN);
149    aad_extension.extend_from_slice(&[0u8; 4]);
150    aad_extension.extend_from_slice(header);
151
152    let ciphertext = plugin
153        .encrypt_submessage(local, remote_list, body, &aad_extension)
154        .map_err(SecurityRtpsError::Crypto)?;
155
156    let body_len = u16::try_from(ciphertext.len())
157        .map_err(|_| SecurityRtpsError::Truncated("SRTPS body > u16"))?;
158
159    let mut out = Vec::with_capacity(
160        RTPS_HEADER_LEN    // plaintext header
161            + 4 + 16           // SRTPS_PREFIX
162            + 4 + ciphertext.len() // crypted body framed
163            + 4, // SRTPS_POSTFIX
164    );
165    out.extend_from_slice(header);
166
167    // SRTPS_PREFIX: 16-byte TransformIdentifier (0x00..0x00 = single-plugin path; multi-plugin identifiers are hand-allocated in the DCPS runtime).
168    push_header(&mut out, SRTPS_PREFIX, 16);
169    out.extend_from_slice(&[0u8; 16]);
170
171    // Hook the cipher body in as a single submessage-like structure.
172    // Here we set the CT bytes directly — the receiver does not learn the body
173    // extent from the submessage length header of the POSTFIX,
174    // so we need our own length marker. We use
175    // a synthetic SEC_BODY shape: [0x30 0x01 len_lo len_hi ...].
176    push_header(&mut out, crate::codec::SEC_BODY, body_len);
177    out.extend_from_slice(&ciphertext);
178
179    // SRTPS_POSTFIX empty — single-receiver mode.
180    push_header(&mut out, SRTPS_POSTFIX, 0);
181
182    Ok(out)
183}
184
185/// Unwraps a whole RTPS message. Expects the same format as
186/// [`encode_secured_rtps_message`]. Returns the reconstructed
187/// plaintext message (`[header | body]`).
188///
189/// # Errors
190/// Tampered header, wire inconsistency, crypto verify fail.
191pub fn decode_secured_rtps_message(
192    plugin: &dyn CryptographicPlugin,
193    local: CryptoHandle,
194    remote: CryptoHandle,
195    wire: &[u8],
196) -> Result<Vec<u8>, SecurityRtpsError> {
197    if wire.len() < RTPS_HEADER_LEN {
198        return Err(SecurityRtpsError::Truncated("rtps-message header"));
199    }
200    let header = &wire[..RTPS_HEADER_LEN];
201    let rest = &wire[RTPS_HEADER_LEN..];
202
203    // SRTPS_PREFIX: 4 byte header + 16 byte body.
204    if rest.len() < 4 + 16 {
205        return Err(SecurityRtpsError::Truncated("SRTPS_PREFIX"));
206    }
207    if rest[0] != SRTPS_PREFIX {
208        return Err(SecurityRtpsError::UnexpectedSubmessageId {
209            pos: 0,
210            expected: SRTPS_PREFIX,
211            got: rest[0],
212        });
213    }
214    if rest[1] & FLAG_LE == 0 {
215        return Err(SecurityRtpsError::BigEndianNotSupported);
216    }
217    // prefix-body length (must be 16, but we blindly follow the
218    // value from the header so future extensions are tolerated).
219    let mut plen_b = [0u8; 2];
220    plen_b.copy_from_slice(&rest[2..4]);
221    let plen = u16::from_le_bytes(plen_b) as usize;
222    let after_prefix = 4 + plen;
223    if rest.len() < after_prefix {
224        return Err(SecurityRtpsError::Truncated("SRTPS_PREFIX body"));
225    }
226    let rest = &rest[after_prefix..];
227
228    // Body-Submessage (SEC_BODY-Kind).
229    if rest.len() < 4 {
230        return Err(SecurityRtpsError::Truncated("SRTPS body header"));
231    }
232    if rest[0] != crate::codec::SEC_BODY {
233        return Err(SecurityRtpsError::UnexpectedSubmessageId {
234            pos: 1,
235            expected: crate::codec::SEC_BODY,
236            got: rest[0],
237        });
238    }
239    if rest[1] & FLAG_LE == 0 {
240        return Err(SecurityRtpsError::BigEndianNotSupported);
241    }
242    let mut blen_b = [0u8; 2];
243    blen_b.copy_from_slice(&rest[2..4]);
244    let blen = u16::from_le_bytes(blen_b) as usize;
245    let after_body = 4 + blen;
246    if rest.len() < after_body {
247        return Err(SecurityRtpsError::Truncated("SRTPS body payload"));
248    }
249    let ciphertext = &rest[4..after_body];
250    let after_body_rest = &rest[after_body..];
251
252    // SRTPS_POSTFIX.
253    if after_body_rest.len() < 4 {
254        return Err(SecurityRtpsError::Truncated("SRTPS_POSTFIX"));
255    }
256    if after_body_rest[0] != SRTPS_POSTFIX {
257        return Err(SecurityRtpsError::UnexpectedSubmessageId {
258            pos: 2,
259            expected: SRTPS_POSTFIX,
260            got: after_body_rest[0],
261        });
262    }
263
264    // AAD extension symmetric to the encoder.
265    let mut aad_extension = Vec::with_capacity(4 + RTPS_HEADER_LEN);
266    aad_extension.extend_from_slice(&[0u8; 4]);
267    aad_extension.extend_from_slice(header);
268
269    let plain_body = plugin
270        .decrypt_submessage(local, remote, ciphertext, &aad_extension)
271        .map_err(SecurityRtpsError::Crypto)?;
272
273    let mut out = Vec::with_capacity(RTPS_HEADER_LEN + plain_body.len());
274    out.extend_from_slice(header);
275    out.extend_from_slice(&plain_body);
276    Ok(out)
277}
278
279#[cfg(test)]
280#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
281mod tests {
282    use super::*;
283    use zerodds_security::authentication::{IdentityHandle, SharedSecretHandle};
284    use zerodds_security::error::SecurityErrorKind;
285    use zerodds_security_crypto::AesGcmCryptoPlugin;
286
287    fn make_plugin() -> (AesGcmCryptoPlugin, CryptoHandle, CryptoHandle) {
288        let mut p = AesGcmCryptoPlugin::new();
289        let local = p
290            .register_local_participant(IdentityHandle(1), &[])
291            .unwrap();
292        let remote = p
293            .register_matched_remote_participant(local, IdentityHandle(2), SharedSecretHandle(1))
294            .unwrap();
295        (p, local, remote)
296    }
297
298    fn fake_rtps_message(body: &[u8]) -> Vec<u8> {
299        // 20-byte header: "RTPS" + version(2) + vendor(2) + prefix(12)
300        let mut m = Vec::with_capacity(RTPS_HEADER_LEN + body.len());
301        m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
302        m.extend_from_slice(&[0u8; 12]); // guid prefix
303        m.extend_from_slice(body);
304        m
305    }
306
307    #[test]
308    fn encode_keeps_header_in_plaintext() {
309        let (p, local, remote) = make_plugin();
310        let msg = fake_rtps_message(b"[DATA submessage plaintext]");
311        let wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
312        // First 4 bytes = "RTPS" magic.
313        assert_eq!(&wire[..4], b"RTPS");
314        // The rest of the 20 bytes is identical to msg[..20].
315        assert_eq!(&wire[..RTPS_HEADER_LEN], &msg[..RTPS_HEADER_LEN]);
316        // SRTPS_PREFIX follows directly.
317        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
318    }
319
320    #[test]
321    fn encode_body_is_not_in_wire_plain() {
322        let (p, local, remote) = make_plugin();
323        let secret_body = b"TOP-SECRET submessage body";
324        let msg = fake_rtps_message(secret_body);
325        let wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
326        assert!(
327            !wire.windows(secret_body.len()).any(|w| w == secret_body),
328            "plaintext body must be encrypted"
329        );
330    }
331
332    #[test]
333    fn cross_instance_srtps_roundtrip_via_token() {
334        // E3b reproduction: A encodes message-level SRTPS with ITS local key;
335        // B decodes with the key exchanged via ParticipantCryptoToken
336        // (two plugin instances = the real cross-instance/cross-vendor case).
337        // The existing #16 tests use ONLY the same handle (encode+decode with
338        // the same `local`) and never hit the slot/key asymmetry.
339        let mut pa = AesGcmCryptoPlugin::new();
340        let local_a = pa
341            .register_local_participant(IdentityHandle(1), &[])
342            .unwrap();
343        let token_a = pa
344            .create_local_participant_crypto_tokens(local_a, CryptoHandle(0))
345            .unwrap();
346
347        let mut pb = AesGcmCryptoPlugin::new();
348        let local_b = pb
349            .register_local_participant(IdentityHandle(1), &[])
350            .unwrap();
351        let remote_a_at_b = pb
352            .register_matched_remote_participant(local_b, IdentityHandle(2), SharedSecretHandle(1))
353            .unwrap();
354        pb.set_remote_participant_crypto_tokens(local_b, remote_a_at_b, &token_a)
355            .unwrap();
356
357        let msg = fake_rtps_message(b"[SEDP DATA cross-instance srtps]");
358        let wire = encode_secured_rtps_message(&pa, local_a, &[], &msg).unwrap();
359        let back = decode_secured_rtps_message(&pb, remote_a_at_b, remote_a_at_b, &wire)
360            .expect("cross-instance decode (E3b)");
361        assert_eq!(back, msg, "cross-instance SRTPS body must recover");
362    }
363
364    #[test]
365    fn message_roundtrip_recovers_body() {
366        let (p, local, remote) = make_plugin();
367        let body = b"[HEARTBEAT][DATA][GAP]";
368        let msg = fake_rtps_message(body);
369        let wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
370        let back = decode_secured_rtps_message(&p, local, remote, &wire).unwrap();
371        assert_eq!(back, msg);
372    }
373
374    #[test]
375    fn message_too_short_rejected() {
376        let (p, local, remote) = make_plugin();
377        let err = encode_secured_rtps_message(&p, local, &[remote], &[0u8; 10]).unwrap_err();
378        assert!(matches!(err, SecurityRtpsError::Truncated(_)));
379    }
380
381    #[test]
382    fn tampered_ciphertext_fails_verify() {
383        let (p, local, remote) = make_plugin();
384        let msg = fake_rtps_message(b"secure submessage stream");
385        let mut wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
386        // Flip a byte in the ciphertext region (after header + prefix + body-hdr).
387        let flip_idx = RTPS_HEADER_LEN + 4 + 16 + 4 + 12;
388        wire[flip_idx] ^= 0x10;
389        let err = decode_secured_rtps_message(&p, local, remote, &wire).unwrap_err();
390        match err {
391            SecurityRtpsError::Crypto(e) => {
392                assert_eq!(e.kind, SecurityErrorKind::CryptoFailed);
393            }
394            other => panic!("expected Crypto error, got {other:?}"),
395        }
396    }
397
398    #[test]
399    fn missing_srtps_prefix_rejected() {
400        let (p, local, remote) = make_plugin();
401        let msg = fake_rtps_message(b"x");
402        let mut wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
403        wire[RTPS_HEADER_LEN] = 0x15; // fake andere submessage
404        let err = decode_secured_rtps_message(&p, local, remote, &wire).unwrap_err();
405        assert!(matches!(
406            err,
407            SecurityRtpsError::UnexpectedSubmessageId {
408                pos: 0,
409                expected: SRTPS_PREFIX,
410                ..
411            }
412        ));
413    }
414
415    #[test]
416    fn psk_encode_sets_pre_shared_key_flag() {
417        let (p, local, remote) = make_plugin();
418        let msg = fake_rtps_message(b"psk-protected body");
419        let wire = encode_secured_rtps_message_psk(&p, local, &[remote], &msg).unwrap();
420        assert_eq!(wire[RTPS_HEADER_LEN], SRTPS_PREFIX);
421        // The flags byte carries both LE and PRE_SHARED_KEY_FLAG.
422        let flags = wire[RTPS_HEADER_LEN + 1];
423        assert!(flags & FLAG_LE != 0);
424        assert!(flags & PRE_SHARED_KEY_FLAG != 0);
425        assert_eq!(srtps_psk_flag(&wire), Some(true));
426    }
427
428    #[test]
429    fn non_psk_encode_does_not_set_pre_shared_key_flag() {
430        let (p, local, remote) = make_plugin();
431        let msg = fake_rtps_message(b"non-psk body");
432        let wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
433        assert_eq!(srtps_psk_flag(&wire), Some(false));
434    }
435
436    #[test]
437    fn psk_encoded_message_decodes_with_classic_decoder() {
438        // Spec §10.9: the wire layout is identical — the PSK flag in the
439        // SRTPS_PREFIX is informative. The classic decoder may still
440        // unpack the body (the LE bit is set).
441        let (p, local, remote) = make_plugin();
442        let msg = fake_rtps_message(b"interop-test");
443        let wire = encode_secured_rtps_message_psk(&p, local, &[remote], &msg).unwrap();
444        let back = decode_secured_rtps_message(&p, local, remote, &wire).unwrap();
445        assert_eq!(back, msg);
446    }
447
448    #[test]
449    fn srtps_psk_flag_returns_none_for_non_srtps() {
450        assert_eq!(srtps_psk_flag(&[]), None);
451        assert_eq!(srtps_psk_flag(&[0u8; 30]), None);
452    }
453
454    #[test]
455    fn big_endian_srtps_rejected() {
456        let (p, local, remote) = make_plugin();
457        let msg = fake_rtps_message(b"x");
458        let mut wire = encode_secured_rtps_message(&p, local, &[remote], &msg).unwrap();
459        // Set the flags byte at SRTPS_PREFIX to BE.
460        wire[RTPS_HEADER_LEN + 1] = 0x00;
461        let err = decode_secured_rtps_message(&p, local, remote, &wire).unwrap_err();
462        assert!(matches!(err, SecurityRtpsError::BigEndianNotSupported));
463    }
464}