Skip to main content

zerodds_security/
token.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! DDS-Security 1.2 token structures (DataHolder + property records).
5//!
6//! Spec: §7.2.6 (Property_t / BinaryProperty_t), §7.2.7 (DataHolder),
7//! §7.4.1.4 (IdentityToken in the SPDP announce).
8//!
9//! A **DataHolder** is the generic wire representation of all
10//! security tokens (IdentityToken, PermissionsToken, IdentityStatus-
11//! Token, AuthRequestMessageToken, HandshakeMessageToken, CryptoToken,
12//! ParticipantCryptoToken, etc.). Fields:
13//!
14//! ```text
15//! struct DataHolder_t {
16//!     string                       class_id;
17//!     sequence<Property_t>         properties;
18//!     sequence<BinaryProperty_t>   binary_properties;
19//! };
20//! struct Property_t        { string name; string value; };
21//! struct BinaryProperty_t  { string name; sequence<octet> value; };
22//! ```
23//!
24//! IMPORTANT: the wire `Property_t` (spec §7.2.6 Tab.5) has **no**
25//! `propagate` field — that is local filter state in the plug-configured
26//! `zerodds_security::PropertyList`.
27//!
28//! The encoding is OMG-CDR (XCDR1) — the DataHolder is serialized as a
29//! big-/little-endian stream, depending on which encapsulation
30//! context it sits in (typically PL_CDR_LE of the ParameterList, i.e. LE).
31//!
32//! # Usage
33//!
34//! ```
35//! use zerodds_security::token::{DataHolder, IdentityToken};
36//! let tok = IdentityToken::pki_dh_v12("01:23:45", "rsa-2048", "FA:CE", "rsa-2048");
37//! let bytes = tok.to_cdr_le();
38//! let back = DataHolder::from_cdr_le(&bytes).unwrap();
39//! assert_eq!(tok, back);
40//! ```
41
42extern crate alloc;
43
44use alloc::string::{String, ToString};
45use alloc::vec::Vec;
46
47use crate::error::{SecurityError, SecurityErrorKind, SecurityResult};
48
49/// Spec §7.2.6 Tab.5 — wire `Property_t` (`name` + `value`, both
50/// CDR strings). Unlike [`crate::Property`], no `propagate`
51/// field; token properties are always propagated.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct WireProperty {
54    /// Property name (reverse-DNS, e.g. `"dds.cert.sn"`).
55    pub name: String,
56    /// Property value (UTF-8).
57    pub value: String,
58}
59
60impl WireProperty {
61    /// Constructor.
62    #[must_use]
63    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
64        Self {
65            name: name.into(),
66            value: value.into(),
67        }
68    }
69}
70
71/// Spec §7.2.6 Tab.6 — wire `BinaryProperty_t` (`name` + `value` as
72/// `sequence<octet>`).
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub struct BinaryProperty {
75    /// Property name (reverse-DNS).
76    pub name: String,
77    /// Raw byte value.
78    pub value: Vec<u8>,
79}
80
81impl BinaryProperty {
82    /// Constructor.
83    #[must_use]
84    pub fn new(name: impl Into<String>, value: impl Into<Vec<u8>>) -> Self {
85        Self {
86            name: name.into(),
87            value: value.into(),
88        }
89    }
90}
91
92/// Spec §7.2.7 Tab.7 — generic `DataHolder_t` wire struct. All
93/// security tokens are type aliases of `DataHolder` with a fixed
94/// `class_id` value.
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub struct DataHolder {
97    /// Plugin class id, e.g. `"DDS:Auth:PKI-DH:1.2"`.
98    pub class_id: String,
99    /// String properties (spec §7.2.6 Tab.5).
100    pub properties: Vec<WireProperty>,
101    /// Binary properties (spec §7.2.6 Tab.6).
102    pub binary_properties: Vec<BinaryProperty>,
103}
104
105impl DataHolder {
106    /// Constructor with only `class_id`.
107    #[must_use]
108    pub fn new(class_id: impl Into<String>) -> Self {
109        Self {
110            class_id: class_id.into(),
111            properties: Vec::new(),
112            binary_properties: Vec::new(),
113        }
114    }
115
116    /// Builder: adds a string property.
117    #[must_use]
118    pub fn with_property(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
119        self.properties.push(WireProperty::new(name, value));
120        self
121    }
122
123    /// Builder: adds a binary property.
124    #[must_use]
125    pub fn with_binary_property(
126        mut self,
127        name: impl Into<String>,
128        value: impl Into<Vec<u8>>,
129    ) -> Self {
130        self.binary_properties
131            .push(BinaryProperty::new(name, value));
132        self
133    }
134
135    /// Mut variant: sets a string property with replace-on-dup
136    /// semantics (if the name already exists, the old value is
137    /// overwritten — spec §7.2.6: per token each property
138    /// name may appear only once).
139    pub fn set_property(&mut self, name: impl Into<String>, value: impl Into<String>) {
140        let n = name.into();
141        if let Some(existing) = self.properties.iter_mut().find(|p| p.name == n) {
142            existing.value = value.into();
143        } else {
144            self.properties.push(WireProperty {
145                name: n,
146                value: value.into(),
147            });
148        }
149    }
150
151    /// Mut variant: sets a binary property with replace-on-dup.
152    pub fn set_binary_property(&mut self, name: impl Into<String>, value: impl Into<Vec<u8>>) {
153        let n = name.into();
154        if let Some(existing) = self.binary_properties.iter_mut().find(|p| p.name == n) {
155            existing.value = value.into();
156        } else {
157            self.binary_properties.push(BinaryProperty {
158                name: n,
159                value: value.into(),
160            });
161        }
162    }
163
164    /// Looks up a string property by name.
165    #[must_use]
166    pub fn property(&self, name: &str) -> Option<&str> {
167        self.properties
168            .iter()
169            .find(|p| p.name == name)
170            .map(|p| p.value.as_str())
171    }
172
173    /// Looks up a binary property by name.
174    #[must_use]
175    pub fn binary_property(&self, name: &str) -> Option<&[u8]> {
176        self.binary_properties
177            .iter()
178            .find(|p| p.name == name)
179            .map(|p| p.value.as_slice())
180    }
181
182    /// XCDR1 little-endian encoder. Returns the bytes without the
183    /// encapsulation header — that is supplied by the caller (e.g. the
184    /// ParameterList value).
185    #[must_use]
186    pub fn to_cdr_le(&self) -> Vec<u8> {
187        encode(self, true)
188    }
189
190    /// XCDR1-Big-Endian-Encoder.
191    #[must_use]
192    pub fn to_cdr_be(&self) -> Vec<u8> {
193        encode(self, false)
194    }
195
196    /// XCDR1-Decoder, Little-Endian.
197    ///
198    /// # Errors
199    /// `BadArgument` if the bytes are not spec-conformant (e.g.
200    /// premature end, overly long length prefixes).
201    pub fn from_cdr_le(bytes: &[u8]) -> SecurityResult<Self> {
202        decode(bytes, true)
203    }
204
205    /// Like [`from_cdr_le`](Self::from_cdr_le), additionally returns the number of
206    /// bytes consumed. For inline `sequence<DataHolder>` decoding (spec
207    /// `GenericMessageData`), where multiple DataHolders lie one after another
208    /// without a length prefix.
209    ///
210    /// # Errors
211    /// `BadArgument` on non-spec-conformant bytes.
212    pub fn from_cdr_le_consumed(bytes: &[u8]) -> SecurityResult<(Self, usize)> {
213        decode_consumed(bytes, true)
214    }
215
216    /// XCDR1-Decoder, Big-Endian.
217    ///
218    /// # Errors
219    /// `BadArgument` if the bytes are not spec-conformant.
220    pub fn from_cdr_be(bytes: &[u8]) -> SecurityResult<Self> {
221        decode(bytes, false)
222    }
223}
224
225// ---------------------------------------------------------------------
226// Type aliases for the token families (spec name / class_id)
227// ---------------------------------------------------------------------
228
229/// IdentityToken (spec §7.4.1.4 Tab.16; PKI-DH §10.3.2 Tab.51).
230/// Sent in the SPDP announce as `PID_IDENTITY_TOKEN` (0x1001).
231pub type IdentityToken = DataHolder;
232
233/// PermissionsToken (spec §7.4.1.5 Tab.17; PKI-Permissions §10.4.2
234/// Tab.65). `PID_PERMISSIONS_TOKEN` (0x1002).
235pub type PermissionsToken = DataHolder;
236
237/// IdentityStatusToken (spec §7.4.1.6, §10.3.2 Tab.53). `PID_IDENTITY_
238/// STATUS_TOKEN` (0x1006). Carrier for OCSP live status.
239pub type IdentityStatusToken = DataHolder;
240
241/// PermissionsCredentialToken (spec §10.4.2 Tab.64). Transported in the
242/// stateless-topic handshake, not in SPDP.
243pub type PermissionsCredentialToken = DataHolder;
244
245/// AuthRequestMessageToken (Spec §10.3.2 Tab.55).
246pub type AuthRequestMessageToken = DataHolder;
247
248/// HandshakeMessageToken — Request/Reply/Final (Spec §10.3.2 Tab.56-58).
249pub type HandshakeMessageToken = DataHolder;
250
251/// CryptoToken (Spec §10.5.2 Tab.72).
252pub type CryptoToken = DataHolder;
253
254// ---------------------------------------------------------------------
255// Convenience constructors for the typical token forms.
256// ---------------------------------------------------------------------
257
258/// Plugin class-id constants — spec-1.2-versioned (see C3.3).
259pub mod class_id {
260    /// Builtin PKI authentication (§10.3.2.1 Tab.51).
261    pub const AUTH_PKI_DH_V12: &str = "DDS:Auth:PKI-DH:1.2";
262    /// Builtin permissions access control (§10.4 Tab.69).
263    pub const ACCESS_PERMISSIONS_V12: &str = "DDS:Access:Permissions:1.2";
264    /// Builtin AES-GCM-GMAC crypto (§10.5 Tab.72).
265    pub const CRYPTO_AES_GCM_GMAC_V12: &str = "DDS:Crypto:AES-GCM-GMAC:1.2";
266    /// Permissions credential (§10.4.2 Tab.64).
267    pub const ACCESS_PERMISSIONS_CREDENTIAL: &str = "DDS:Access:PermissionsCredential";
268}
269
270/// Property names — spec 1.2 (selection).
271pub mod prop {
272    /// IdentityToken: cert subject name serial. Spec §10.3.2.1 Tab.51.
273    pub const CERT_SN: &str = "dds.cert.sn";
274    /// IdentityToken: cert signature algorithm.
275    pub const CERT_ALGO: &str = "dds.cert.algo";
276    /// IdentityToken: CA subject name serial.
277    pub const CA_SN: &str = "dds.ca.sn";
278    /// IdentityToken: CA signature algorithm.
279    pub const CA_ALGO: &str = "dds.ca.algo";
280    /// PermissionsToken: permissions-CA subject name serial.
281    /// Spec §10.4.2 Tab.65.
282    pub const PERM_CA_SN: &str = "dds.perm_ca.sn";
283    /// PermissionsToken: permissions-CA signature algorithm.
284    pub const PERM_CA_ALGO: &str = "dds.perm_ca.algo";
285}
286
287impl IdentityToken {
288    /// Builtin helper for the PKI-DH IdentityToken (§10.3.2.1 Tab.51).
289    /// Properties `dds.cert.sn`, `dds.cert.algo`, `dds.ca.sn`,
290    /// `dds.ca.algo`. No binary_properties.
291    #[must_use]
292    pub fn pki_dh_v12(
293        cert_sn: impl Into<String>,
294        cert_algo: impl Into<String>,
295        ca_sn: impl Into<String>,
296        ca_algo: impl Into<String>,
297    ) -> Self {
298        DataHolder::new(class_id::AUTH_PKI_DH_V12)
299            .with_property(prop::CERT_SN, cert_sn)
300            .with_property(prop::CERT_ALGO, cert_algo)
301            .with_property(prop::CA_SN, ca_sn)
302            .with_property(prop::CA_ALGO, ca_algo)
303    }
304
305    /// Builtin helper for the permissions token (§10.4.2 Tab.65).
306    /// `class_id="DDS:Access:Permissions:1.2"`, properties
307    /// `dds.perm_ca.sn` + `dds.perm_ca.algo`.
308    #[must_use]
309    pub fn permissions_v12(perm_ca_sn: impl Into<String>, perm_ca_algo: impl Into<String>) -> Self {
310        DataHolder::new(class_id::ACCESS_PERMISSIONS_V12)
311            .with_property(prop::PERM_CA_SN, perm_ca_sn)
312            .with_property(prop::PERM_CA_ALGO, perm_ca_algo)
313    }
314}
315
316// ---------------------------------------------------------------------
317// XCDR1-Codec
318// ---------------------------------------------------------------------
319//
320// Layout (LE or BE, depending on `little_endian`):
321//   string class_id              -- aligned 4
322//   sequence<Property> props     -- aligned 4
323//     uint32 count               -- 4 byte
324//     N x { string name; string value; }  -- aligned 4 each
325//   sequence<BinaryProperty> bp  -- aligned 4
326//     uint32 count
327//     N x { string name; sequence<octet> value; }  -- aligned 4 each
328//
329// CDR string: uint32 length (incl. trailing \0) + UTF-8 bytes + \0 +
330// padding to 4 bytes. Length=0 is allowed (= empty string).
331
332/// Maximum counts/lengths we accept from the wire —
333/// DoS cap. 64 KiB per token, 256 properties, 256 binary properties,
334/// 8 KiB per property value.
335const MAX_TOKEN_BYTES: usize = 64 * 1024;
336const MAX_PROPS: u32 = 256;
337const MAX_BIN_PROPS: u32 = 256;
338const MAX_STRING_LEN: u32 = 8 * 1024;
339const MAX_BINARY_LEN: u32 = 8 * 1024;
340
341fn encode(d: &DataHolder, le: bool) -> Vec<u8> {
342    let mut out = Vec::with_capacity(64);
343    encode_string(&mut out, &d.class_id, le);
344    encode_u32(&mut out, d.properties.len() as u32, le);
345    for p in &d.properties {
346        encode_string(&mut out, &p.name, le);
347        encode_string(&mut out, &p.value, le);
348    }
349    encode_u32(&mut out, d.binary_properties.len() as u32, le);
350    for p in &d.binary_properties {
351        encode_string(&mut out, &p.name, le);
352        encode_octet_seq(&mut out, &p.value, le);
353    }
354    out
355}
356
357fn decode(bytes: &[u8], le: bool) -> SecurityResult<DataHolder> {
358    decode_consumed(bytes, le).map(|(dh, _)| dh)
359}
360
361/// Like [`decode`], additionally returns the number of bytes consumed —
362/// needed for the inline decoding of `sequence<DataHolder>` (spec
363/// `GenericMessageData`), where the DataHolders lie one after another
364/// without a length prefix and the caller must advance the cursor.
365fn decode_consumed(bytes: &[u8], le: bool) -> SecurityResult<(DataHolder, usize)> {
366    if bytes.len() > MAX_TOKEN_BYTES {
367        return Err(SecurityError::new(
368            SecurityErrorKind::BadArgument,
369            "token: payload exceeds DoS cap",
370        ));
371    }
372    let mut cur = Cursor::new(bytes);
373    let class_id = cur.read_string(le)?;
374    let prop_count = cur.read_u32(le)?;
375    if prop_count > MAX_PROPS {
376        return Err(SecurityError::new(
377            SecurityErrorKind::BadArgument,
378            "token: property count exceeds cap",
379        ));
380    }
381    let mut properties = Vec::with_capacity(prop_count as usize);
382    for _ in 0..prop_count {
383        let name = cur.read_string(le)?;
384        let value = cur.read_string(le)?;
385        properties.push(WireProperty { name, value });
386    }
387    let bin_count = cur.read_u32(le)?;
388    if bin_count > MAX_BIN_PROPS {
389        return Err(SecurityError::new(
390            SecurityErrorKind::BadArgument,
391            "token: binary_property count exceeds cap",
392        ));
393    }
394    let mut binary_properties = Vec::with_capacity(bin_count as usize);
395    for _ in 0..bin_count {
396        let name = cur.read_string(le)?;
397        let value = cur.read_octet_seq(le)?;
398        binary_properties.push(BinaryProperty { name, value });
399    }
400    Ok((
401        DataHolder {
402            class_id,
403            properties,
404            binary_properties,
405        },
406        cur.pos,
407    ))
408}
409
410fn align_to(out: &mut Vec<u8>, n: usize) {
411    let pad = (n - out.len() % n) % n;
412    for _ in 0..pad {
413        out.push(0);
414    }
415}
416
417fn encode_u32(out: &mut Vec<u8>, v: u32, le: bool) {
418    align_to(out, 4);
419    if le {
420        out.extend_from_slice(&v.to_le_bytes());
421    } else {
422        out.extend_from_slice(&v.to_be_bytes());
423    }
424}
425
426fn encode_string(out: &mut Vec<u8>, s: &str, le: bool) {
427    let bytes = s.as_bytes();
428    let len = (bytes.len() + 1) as u32;
429    encode_u32(out, len, le);
430    out.extend_from_slice(bytes);
431    out.push(0);
432}
433
434fn encode_octet_seq(out: &mut Vec<u8>, v: &[u8], le: bool) {
435    encode_u32(out, v.len() as u32, le);
436    out.extend_from_slice(v);
437}
438
439struct Cursor<'a> {
440    buf: &'a [u8],
441    pos: usize,
442}
443
444impl<'a> Cursor<'a> {
445    fn new(buf: &'a [u8]) -> Self {
446        Self { buf, pos: 0 }
447    }
448
449    fn align_to(&mut self, n: usize) {
450        let pad = (n - self.pos % n) % n;
451        self.pos = self.pos.saturating_add(pad);
452    }
453
454    fn read_u32(&mut self, le: bool) -> SecurityResult<u32> {
455        self.align_to(4);
456        if self.pos + 4 > self.buf.len() {
457            return Err(SecurityError::new(
458                SecurityErrorKind::BadArgument,
459                "token: truncated u32",
460            ));
461        }
462        let raw = [
463            self.buf[self.pos],
464            self.buf[self.pos + 1],
465            self.buf[self.pos + 2],
466            self.buf[self.pos + 3],
467        ];
468        self.pos += 4;
469        Ok(if le {
470            u32::from_le_bytes(raw)
471        } else {
472            u32::from_be_bytes(raw)
473        })
474    }
475
476    fn read_string(&mut self, le: bool) -> SecurityResult<String> {
477        let len = self.read_u32(le)?;
478        if len > MAX_STRING_LEN {
479            return Err(SecurityError::new(
480                SecurityErrorKind::BadArgument,
481                "token: string exceeds cap",
482            ));
483        }
484        if len == 0 {
485            return Ok(String::new());
486        }
487        if self.pos + len as usize > self.buf.len() {
488            return Err(SecurityError::new(
489                SecurityErrorKind::BadArgument,
490                "token: truncated string",
491            ));
492        }
493        let body = &self.buf[self.pos..self.pos + len as usize];
494        self.pos += len as usize;
495        // Spec: a trailing NUL is mandatory; UTF-8 must be valid.
496        if let Some((_, rest)) = body.split_last() {
497            if body.last() != Some(&0) {
498                return Err(SecurityError::new(
499                    SecurityErrorKind::BadArgument,
500                    "token: string missing trailing NUL",
501                ));
502            }
503            let s = core::str::from_utf8(rest).map_err(|_| {
504                SecurityError::new(SecurityErrorKind::BadArgument, "token: string not UTF-8")
505            })?;
506            Ok(s.to_string())
507        } else {
508            Err(SecurityError::new(
509                SecurityErrorKind::BadArgument,
510                "token: zero-length string body",
511            ))
512        }
513    }
514
515    fn read_octet_seq(&mut self, le: bool) -> SecurityResult<Vec<u8>> {
516        let len = self.read_u32(le)?;
517        if len > MAX_BINARY_LEN {
518            return Err(SecurityError::new(
519                SecurityErrorKind::BadArgument,
520                "token: binary value exceeds cap",
521            ));
522        }
523        if self.pos + len as usize > self.buf.len() {
524            return Err(SecurityError::new(
525                SecurityErrorKind::BadArgument,
526                "token: truncated binary",
527            ));
528        }
529        let v = self.buf[self.pos..self.pos + len as usize].to_vec();
530        self.pos += len as usize;
531        Ok(v)
532    }
533}
534
535#[cfg(test)]
536#[allow(clippy::expect_used, clippy::unwrap_used)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn empty_data_holder_roundtrip_le() {
542        let dh = DataHolder::new("DDS:Auth:PKI-DH:1.2");
543        let bytes = dh.to_cdr_le();
544        let back = DataHolder::from_cdr_le(&bytes).unwrap();
545        assert_eq!(dh, back);
546    }
547
548    #[test]
549    fn empty_data_holder_roundtrip_be() {
550        let dh = DataHolder::new("DDS:Auth:PKI-DH:1.2");
551        let bytes = dh.to_cdr_be();
552        let back = DataHolder::from_cdr_be(&bytes).unwrap();
553        assert_eq!(dh, back);
554    }
555
556    #[test]
557    fn pki_dh_identity_token_roundtrip() {
558        let tok =
559            IdentityToken::pki_dh_v12("01:23:45:67", "ECDSA-SHA256", "FA:CE:0B:01", "RSA-SHA256");
560        let bytes = tok.to_cdr_le();
561        let back = DataHolder::from_cdr_le(&bytes).unwrap();
562        assert_eq!(tok, back);
563        assert_eq!(back.class_id, "DDS:Auth:PKI-DH:1.2");
564        assert_eq!(back.property("dds.cert.sn"), Some("01:23:45:67"));
565        assert_eq!(back.property("dds.cert.algo"), Some("ECDSA-SHA256"));
566        assert_eq!(back.property("dds.ca.sn"), Some("FA:CE:0B:01"));
567        assert_eq!(back.property("dds.ca.algo"), Some("RSA-SHA256"));
568        assert!(back.binary_properties.is_empty());
569    }
570
571    #[test]
572    fn permissions_token_roundtrip() {
573        let tok = IdentityToken::permissions_v12("DE:AD:BE:EF", "ECDSA-SHA256");
574        let le = tok.to_cdr_le();
575        let be = tok.to_cdr_be();
576        assert_eq!(tok, DataHolder::from_cdr_le(&le).unwrap());
577        assert_eq!(tok, DataHolder::from_cdr_be(&be).unwrap());
578        assert_ne!(le, be, "BE/LE streams differ");
579    }
580
581    #[test]
582    fn token_with_binary_property_roundtrip() {
583        let tok = DataHolder::new("DDS:Auth:PKI-DH:1.2")
584            .with_property("dds.cert.sn", "01:23")
585            .with_binary_property("dds.cert.bytes", vec![0xCA, 0xFE, 0xBA, 0xBE, 0xDE]);
586        let bytes = tok.to_cdr_le();
587        let back = DataHolder::from_cdr_le(&bytes).unwrap();
588        assert_eq!(tok, back);
589        assert_eq!(
590            back.binary_property("dds.cert.bytes"),
591            Some(&[0xCA, 0xFE, 0xBA, 0xBE, 0xDE][..])
592        );
593    }
594
595    #[test]
596    fn cdr_le_layout_class_id_only() {
597        // class_id="A" (1 char + NUL = len=2). Expected stream:
598        //   uint32 len=2 LE: 02 00 00 00
599        //   bytes        :  41 00
600        //   pad-to-4     :  00 00
601        //   prop_count=0 :  00 00 00 00
602        //   bin_count=0  :  00 00 00 00
603        let dh = DataHolder::new("A");
604        let bytes = dh.to_cdr_le();
605        assert_eq!(
606            bytes,
607            vec![
608                0x02, 0x00, 0x00, 0x00, b'A', 0x00, 0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0
609            ]
610        );
611    }
612
613    #[test]
614    fn truncated_buffer_is_error() {
615        let err = DataHolder::from_cdr_le(&[0x10, 0x00, 0x00]).unwrap_err();
616        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
617    }
618
619    #[test]
620    fn property_count_cap_rejects_huge() {
621        // Forge a stream with prop_count = 1_000_000.
622        let mut bytes = Vec::new();
623        encode_string(&mut bytes, "X", true);
624        encode_u32(&mut bytes, 1_000_000, true); // prop count > cap
625        let err = DataHolder::from_cdr_le(&bytes).unwrap_err();
626        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
627    }
628
629    #[test]
630    fn missing_trailing_nul_rejected() {
631        // Forge: class_id len=1 (no NUL).
632        let bytes = vec![0x01, 0x00, 0x00, 0x00, b'A'];
633        let err = DataHolder::from_cdr_le(&bytes).unwrap_err();
634        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
635    }
636
637    #[test]
638    fn dos_cap_overall_payload() {
639        let big = vec![0u8; MAX_TOKEN_BYTES + 1];
640        let err = DataHolder::from_cdr_le(&big).unwrap_err();
641        assert_eq!(err.kind, SecurityErrorKind::BadArgument);
642    }
643}