Skip to main content

matter_cert/
name.rs

1//! Matter distinguished-name (DN) handling.
2//!
3//! A Matter DN is a TLV list of context-tagged attributes. Each
4//! attribute's context tag identifies the attribute kind (per spec
5//! §6.5.6 Table 71); the attribute's value type follows from the
6//! tag (most are UTF-8 strings; Matter-specific identifiers are
7//! unsigned integers).
8
9use matter_codec::{Element, Tag, TlvReader, TlvWriter, Value};
10
11use crate::error::{Error, Result};
12use crate::tlv_tags as tags;
13
14/// A single distinguished-name attribute.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum DnAttribute {
18    // --- Standard X.509 attributes (UTF-8 string-valued) ---
19    /// X.509 `CommonName` (CN) attribute — UTF-8 string.
20    CommonName(String),
21    /// X.509 `Surname` (SN) attribute — UTF-8 string.
22    Surname(String),
23    /// X.509 `SerialNumber` attribute — UTF-8 string.
24    SerialNumber(String),
25    /// X.509 `CountryName` (C) attribute — UTF-8 string (2-character ISO 3166).
26    CountryName(String),
27    /// X.509 `LocalityName` (L) attribute — UTF-8 string.
28    LocalityName(String),
29    /// X.509 `StateOrProvinceName` (ST) attribute — UTF-8 string.
30    StateOrProvinceName(String),
31    /// X.509 `OrganizationName` (O) attribute — UTF-8 string.
32    OrganizationName(String),
33    /// X.509 `OrganizationalUnitName` (OU) attribute — UTF-8 string.
34    OrganizationalUnitName(String),
35    /// X.509 `Title` attribute — UTF-8 string.
36    Title(String),
37    /// X.509 `Name` attribute — UTF-8 string.
38    Name(String),
39    /// X.509 `GivenName` attribute — UTF-8 string.
40    GivenName(String),
41    /// X.509 `Initials` attribute — UTF-8 string.
42    Initials(String),
43    /// X.509 `GenerationQualifier` attribute — UTF-8 string.
44    GenerationQualifier(String),
45    /// X.509 `DnQualifier` attribute — UTF-8 string.
46    DnQualifier(String),
47    /// X.509 `Pseudonym` attribute — UTF-8 string.
48    Pseudonym(String),
49    /// X.509 `DomainComponent` (DC) attribute — UTF-8 string.
50    DomainComponent(String),
51
52    // --- Matter-specific attributes ---
53    /// Matter Node Identifier (operational identity), spec §6.5.6 tag 17.
54    NodeId(u64),
55    /// Matter Intermediate CA Identifier, spec §6.5.6 tag 19.
56    IcacId(u64),
57    /// Matter Root CA Identifier, spec §6.5.6 tag 20.
58    RcacId(u64),
59    /// Matter Fabric Identifier, spec §6.5.6 tag 21.
60    FabricId(u64),
61    /// Matter CASE Authenticated Tag (NOC-CAT), spec §6.5.6 tag 22.
62    CaseAuthenticatedTag(u32),
63
64    /// Matter Vendor Identifier (attestation certificates), CSA OID
65    /// `1.3.6.1.4.1.37244.2.1`.
66    ///
67    /// Used in DAC/PAI/PAA X.509 attestation certificate DNs (Matter
68    /// §6.5.6.1), where the value is a 4-character UPPERCASE-hex
69    /// `PrintableString` (e.g. `0xFFF1` → `"FFF1"`). This is an X.509-only
70    /// attribute: it has no Matter operational-TLV cert encoding (those
71    /// certs use the node/fabric/icac/rcac/case-tag identifiers above).
72    VendorId(u16),
73
74    /// Matter Product Identifier (attestation certificates), CSA OID
75    /// `1.3.6.1.4.1.37244.2.2`.
76    ///
77    /// Used in DAC/PAI X.509 attestation certificate DNs (Matter
78    /// §6.5.6.1), where the value is a 4-character UPPERCASE-hex
79    /// `PrintableString` (e.g. `0x8001` → `"8001"`). Like
80    /// [`DnAttribute::VendorId`], this is an X.509-only attribute with no
81    /// Matter operational-TLV cert encoding.
82    ProductId(u16),
83
84    /// Forward-compatibility fallback for spec-defined attributes
85    /// the typed variants above don't enumerate yet (e.g., tag 18
86    /// matter-firmware-signing-id, tags 23-26 vid/pid variants).
87    Other {
88        /// Context tag number from the wire.
89        tag: u8,
90        /// Value decoded from the wire with its TLV type preserved.
91        value: DnAttributeValue,
92    },
93}
94
95/// The wire-typed value carried inside a [`DnAttribute::Other`].
96///
97/// `#[non_exhaustive]`: TLV admits value types beyond the three surfaced here
98/// (e.g. signed integers, booleans); marking this lets a future variant be
99/// added without a semver break. Downstream `match`es must include a `_` arm.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum DnAttributeValue {
103    /// A UTF-8 string value.
104    Utf8(String),
105    /// An unsigned integer value.
106    Uint(u64),
107    /// A raw byte-string value.
108    Bytes(Vec<u8>),
109}
110
111/// An ordered list of DN attributes.
112#[derive(Debug, Clone, PartialEq, Eq, Default)]
113pub struct DistinguishedName(Vec<DnAttribute>);
114
115impl DistinguishedName {
116    /// Construct from a list of attributes (preserves order).
117    #[must_use]
118    pub fn new(attrs: Vec<DnAttribute>) -> Self {
119        Self(attrs)
120    }
121
122    /// Iterate the attributes in their wire order.
123    pub fn iter(&self) -> core::slice::Iter<'_, DnAttribute> {
124        self.0.iter()
125    }
126
127    /// First Matter Node Identifier attribute, if present.
128    #[must_use]
129    pub fn node_id(&self) -> Option<u64> {
130        self.0.iter().find_map(|a| match a {
131            DnAttribute::NodeId(v) => Some(*v),
132            _ => None,
133        })
134    }
135
136    /// First Matter Fabric Identifier attribute, if present.
137    #[must_use]
138    pub fn fabric_id(&self) -> Option<u64> {
139        self.0.iter().find_map(|a| match a {
140            DnAttribute::FabricId(v) => Some(*v),
141            _ => None,
142        })
143    }
144
145    /// First Matter Root CA Identifier attribute, if present.
146    #[must_use]
147    pub fn rcac_id(&self) -> Option<u64> {
148        self.0.iter().find_map(|a| match a {
149            DnAttribute::RcacId(v) => Some(*v),
150            _ => None,
151        })
152    }
153
154    /// First Matter Intermediate CA Identifier attribute, if present.
155    #[must_use]
156    pub fn icac_id(&self) -> Option<u64> {
157        self.0.iter().find_map(|a| match a {
158            DnAttribute::IcacId(v) => Some(*v),
159            _ => None,
160        })
161    }
162
163    /// First Common Name attribute, if present.
164    #[must_use]
165    pub fn common_name(&self) -> Option<&str> {
166        self.0.iter().find_map(|a| match a {
167            DnAttribute::CommonName(v) => Some(v.as_str()),
168            _ => None,
169        })
170    }
171
172    /// Read a DN from a TLV reader positioned BEFORE the DN's
173    /// `Element::ContainerStart` event. Consumes the list and its
174    /// closing `ContainerEnd`.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the TLV stream is malformed or contains
179    /// invalid DN attributes.
180    // Used only in tests — suppress dead_code lint.
181    #[allow(dead_code)]
182    pub(crate) fn read(reader: &mut TlvReader<'_>) -> Result<Self> {
183        match reader.next()? {
184            Some(Element::ContainerStart {
185                kind: matter_codec::ContainerKind::List,
186                ..
187            }) => {}
188            _ => return Err(Error::WrongFieldType(0)),
189        }
190        Self::read_from_open_list(reader)
191    }
192
193    /// Read a DN from a reader where the `ContainerStart` has already
194    /// been consumed. Used by `MatterCertificate::from_tlv` which has
195    /// dispatched on the issuer / subject context tag via its own
196    /// `next()` call.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the TLV stream is malformed or contains
201    /// invalid DN attributes.
202    pub(crate) fn read_from_open_list(reader: &mut TlvReader<'_>) -> Result<Self> {
203        let mut attrs = Vec::new();
204        loop {
205            match reader.next()? {
206                None => return Err(matter_codec::Error::UnclosedContainer.into()),
207                Some(Element::ContainerEnd) => break,
208                Some(Element::Scalar { tag, value }) => {
209                    let Tag::Context(tag_num) = tag else {
210                        return Err(Error::InvalidDnAttribute(0));
211                    };
212                    attrs.push(decode_attribute(tag_num, value)?);
213                }
214                Some(Element::ContainerStart { .. }) => {
215                    return Err(Error::WrongFieldType(0));
216                }
217                // Element is #[non_exhaustive]; future variants are
218                // wire-format violations inside a DN list.
219                Some(_) => return Err(Error::WrongFieldType(0)),
220            }
221        }
222        Ok(Self(attrs))
223    }
224
225    /// Write the DN as a TLV list under `outer_tag`.
226    ///
227    /// # Errors
228    ///
229    /// Propagates any [`matter_codec::Error`] from the underlying writer.
230    pub(crate) fn write(&self, writer: &mut TlvWriter<'_>, outer_tag: Tag) -> Result<()> {
231        writer.start_list(outer_tag)?;
232        for attr in &self.0 {
233            encode_attribute(writer, attr)?;
234        }
235        writer.end_container()?;
236        Ok(())
237    }
238}
239
240impl<'a> IntoIterator for &'a DistinguishedName {
241    type Item = &'a DnAttribute;
242    type IntoIter = core::slice::Iter<'a, DnAttribute>;
243
244    fn into_iter(self) -> Self::IntoIter {
245        self.0.iter()
246    }
247}
248
249/// Decode a single DN attribute given its context tag and TLV value.
250/// `pub(crate)` so `MatterCertificate::from_tlv` can use it for the
251/// already-consumed-ContainerStart fast path.
252pub(crate) fn decode_attribute(tag: u8, value: Value) -> Result<DnAttribute> {
253    use DnAttribute as A;
254    match (tag, value) {
255        (tags::DN_COMMON_NAME, Value::Utf8(s)) => Ok(A::CommonName(s)),
256        (tags::DN_SURNAME, Value::Utf8(s)) => Ok(A::Surname(s)),
257        (tags::DN_SERIAL_NUMBER, Value::Utf8(s)) => Ok(A::SerialNumber(s)),
258        (tags::DN_COUNTRY_NAME, Value::Utf8(s)) => Ok(A::CountryName(s)),
259        (tags::DN_LOCALITY_NAME, Value::Utf8(s)) => Ok(A::LocalityName(s)),
260        (tags::DN_STATE_OR_PROVINCE, Value::Utf8(s)) => Ok(A::StateOrProvinceName(s)),
261        (tags::DN_ORGANIZATION_NAME, Value::Utf8(s)) => Ok(A::OrganizationName(s)),
262        (tags::DN_ORG_UNIT_NAME, Value::Utf8(s)) => Ok(A::OrganizationalUnitName(s)),
263        (tags::DN_TITLE, Value::Utf8(s)) => Ok(A::Title(s)),
264        (tags::DN_NAME, Value::Utf8(s)) => Ok(A::Name(s)),
265        (tags::DN_GIVEN_NAME, Value::Utf8(s)) => Ok(A::GivenName(s)),
266        (tags::DN_INITIALS, Value::Utf8(s)) => Ok(A::Initials(s)),
267        (tags::DN_GENERATION_QUALIFIER, Value::Utf8(s)) => Ok(A::GenerationQualifier(s)),
268        (tags::DN_DN_QUALIFIER, Value::Utf8(s)) => Ok(A::DnQualifier(s)),
269        (tags::DN_PSEUDONYM, Value::Utf8(s)) => Ok(A::Pseudonym(s)),
270        (tags::DN_DOMAIN_COMPONENT, Value::Utf8(s)) => Ok(A::DomainComponent(s)),
271        (tags::DN_MATTER_NODE_ID, Value::Uint(v)) => Ok(A::NodeId(v)),
272        (tags::DN_MATTER_ICAC_ID, Value::Uint(v)) => Ok(A::IcacId(v)),
273        (tags::DN_MATTER_RCAC_ID, Value::Uint(v)) => Ok(A::RcacId(v)),
274        (tags::DN_MATTER_FABRIC_ID, Value::Uint(v)) => Ok(A::FabricId(v)),
275        (tags::DN_MATTER_NOC_CAT, Value::Uint(v)) => {
276            let v32 = u32::try_from(v).map_err(|_| Error::FieldValueOutOfRange { tag })?;
277            Ok(A::CaseAuthenticatedTag(v32))
278        }
279
280        // Other variants are spec-defined-but-not-typed (e.g., tag 18,
281        // tags 23-26). These tags have no explicit typed variant above,
282        // so any TLV scalar value type is preserved as-is.
283        // is_untyped_dn_tag guards against routing a wrong-typed value
284        // for a known tag (e.g., Uint for DN_COMMON_NAME) through here.
285        (n, Value::Utf8(s)) if is_untyped_dn_tag(n) => Ok(A::Other {
286            tag: n,
287            value: DnAttributeValue::Utf8(s),
288        }),
289        (n, Value::Uint(v)) if is_untyped_dn_tag(n) => Ok(A::Other {
290            tag: n,
291            value: DnAttributeValue::Uint(v),
292        }),
293        (n, Value::Bytes(b)) if is_untyped_dn_tag(n) => Ok(A::Other {
294            tag: n,
295            value: DnAttributeValue::Bytes(b),
296        }),
297
298        // Truly unknown tag — wire-format violation.
299        (n, _) if !is_dn_tag(n) => Err(Error::InvalidDnAttribute(n)),
300
301        // Known tag with wrong-typed value (e.g., Uint for DN_COMMON_NAME).
302        (n, _) => Err(Error::InvalidDnAttributeType(n)),
303    }
304}
305
306fn encode_attribute(writer: &mut TlvWriter<'_>, attr: &DnAttribute) -> Result<()> {
307    use DnAttribute as A;
308    match attr {
309        A::CommonName(s) => writer.put_utf8(Tag::Context(tags::DN_COMMON_NAME), s)?,
310        A::Surname(s) => writer.put_utf8(Tag::Context(tags::DN_SURNAME), s)?,
311        A::SerialNumber(s) => writer.put_utf8(Tag::Context(tags::DN_SERIAL_NUMBER), s)?,
312        A::CountryName(s) => writer.put_utf8(Tag::Context(tags::DN_COUNTRY_NAME), s)?,
313        A::LocalityName(s) => writer.put_utf8(Tag::Context(tags::DN_LOCALITY_NAME), s)?,
314        A::StateOrProvinceName(s) => {
315            writer.put_utf8(Tag::Context(tags::DN_STATE_OR_PROVINCE), s)?;
316        }
317        A::OrganizationName(s) => {
318            writer.put_utf8(Tag::Context(tags::DN_ORGANIZATION_NAME), s)?;
319        }
320        A::OrganizationalUnitName(s) => {
321            writer.put_utf8(Tag::Context(tags::DN_ORG_UNIT_NAME), s)?;
322        }
323        A::Title(s) => writer.put_utf8(Tag::Context(tags::DN_TITLE), s)?,
324        A::Name(s) => writer.put_utf8(Tag::Context(tags::DN_NAME), s)?,
325        A::GivenName(s) => writer.put_utf8(Tag::Context(tags::DN_GIVEN_NAME), s)?,
326        A::Initials(s) => writer.put_utf8(Tag::Context(tags::DN_INITIALS), s)?,
327        A::GenerationQualifier(s) => {
328            writer.put_utf8(Tag::Context(tags::DN_GENERATION_QUALIFIER), s)?;
329        }
330        A::DnQualifier(s) => writer.put_utf8(Tag::Context(tags::DN_DN_QUALIFIER), s)?,
331        A::Pseudonym(s) => writer.put_utf8(Tag::Context(tags::DN_PSEUDONYM), s)?,
332        A::DomainComponent(s) => {
333            writer.put_utf8(Tag::Context(tags::DN_DOMAIN_COMPONENT), s)?;
334        }
335        A::NodeId(v) => writer.put_uint(Tag::Context(tags::DN_MATTER_NODE_ID), *v)?,
336        A::IcacId(v) => writer.put_uint(Tag::Context(tags::DN_MATTER_ICAC_ID), *v)?,
337        A::RcacId(v) => writer.put_uint(Tag::Context(tags::DN_MATTER_RCAC_ID), *v)?,
338        A::FabricId(v) => writer.put_uint(Tag::Context(tags::DN_MATTER_FABRIC_ID), *v)?,
339        A::CaseAuthenticatedTag(v) => {
340            writer.put_uint(Tag::Context(tags::DN_MATTER_NOC_CAT), u64::from(*v))?;
341        }
342        // VID/PID are X.509-attestation-only DN attributes (DAC/PAI/PAA
343        // subject DNs). They have no Matter operational-TLV cert encoding,
344        // so refuse rather than invent a context tag.
345        A::VendorId(_) => return Err(Error::DnAttributeNotTlvEncodable("VendorId")),
346        A::ProductId(_) => return Err(Error::DnAttributeNotTlvEncodable("ProductId")),
347        A::Other { tag, value } => match value {
348            DnAttributeValue::Utf8(s) => writer.put_utf8(Tag::Context(*tag), s)?,
349            DnAttributeValue::Uint(v) => writer.put_uint(Tag::Context(*tag), *v)?,
350            DnAttributeValue::Bytes(b) => writer.put_bytes(Tag::Context(*tag), b)?,
351        },
352    }
353    Ok(())
354}
355
356/// Whether `tag` is in the spec-defined range for DN attributes
357/// (1–26 inclusive). Higher tags are rejected as `InvalidDnAttribute`
358/// because the spec does not define them today.
359const fn is_dn_tag(tag: u8) -> bool {
360    matches!(tag, 1..=26)
361}
362
363/// Whether `tag` is a spec-defined DN tag that does NOT have an
364/// explicit typed variant in [`DnAttribute`]. These are tags for which
365/// we accept any TLV scalar value type and preserve it as
366/// [`DnAttribute::Other`].
367///
368/// Currently: tag 18 (matter-firmware-signing-id) and tags 23-26
369/// (vid/pid variants pending matter.js cross-verification).
370const fn is_untyped_dn_tag(tag: u8) -> bool {
371    matches!(tag, 18 | 23..=26)
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
376mod tests {
377    use super::*;
378
379    fn write_dn(dn: &DistinguishedName) -> Vec<u8> {
380        let mut buf = Vec::new();
381        let mut w = TlvWriter::new(&mut buf);
382        dn.write(&mut w, Tag::Anonymous).unwrap();
383        buf
384    }
385
386    fn read_dn(bytes: &[u8]) -> DistinguishedName {
387        let mut r = TlvReader::new(bytes);
388        DistinguishedName::read(&mut r).unwrap()
389    }
390
391    #[test]
392    fn round_trip_common_name() {
393        let dn = DistinguishedName::new(vec![DnAttribute::CommonName("CN".into())]);
394        let bytes = write_dn(&dn);
395        assert_eq!(read_dn(&bytes), dn);
396    }
397
398    #[test]
399    fn round_trip_matter_node_id() {
400        let dn = DistinguishedName::new(vec![DnAttribute::NodeId(0xDEAD_BEEF_CAFE_BABE)]);
401        let bytes = write_dn(&dn);
402        assert_eq!(read_dn(&bytes), dn);
403    }
404
405    #[test]
406    fn round_trip_multiple_attributes_preserves_order() {
407        let dn = DistinguishedName::new(vec![
408            DnAttribute::FabricId(1),
409            DnAttribute::NodeId(2),
410            DnAttribute::CommonName("device".into()),
411        ]);
412        let bytes = write_dn(&dn);
413        let parsed = read_dn(&bytes);
414        assert_eq!(parsed, dn);
415        assert!(matches!(
416            parsed.iter().next(),
417            Some(DnAttribute::FabricId(1))
418        ));
419    }
420
421    #[test]
422    fn round_trip_other_attribute_for_tag_18() {
423        let dn = DistinguishedName::new(vec![DnAttribute::Other {
424            tag: 18,
425            value: DnAttributeValue::Uint(42),
426        }]);
427        let bytes = write_dn(&dn);
428        assert_eq!(read_dn(&bytes), dn);
429    }
430
431    #[test]
432    fn read_rejects_unknown_dn_tag() {
433        let mut buf = Vec::new();
434        {
435            let mut w = TlvWriter::new(&mut buf);
436            w.start_list(Tag::Anonymous).unwrap();
437            w.put_utf8(Tag::Context(100), "bogus").unwrap();
438            w.end_container().unwrap();
439        }
440        let mut r = TlvReader::new(&buf);
441        assert!(matches!(
442            DistinguishedName::read(&mut r),
443            Err(Error::InvalidDnAttribute(100))
444        ));
445    }
446
447    #[test]
448    fn read_rejects_wrong_type_for_known_tag() {
449        let mut buf = Vec::new();
450        {
451            let mut w = TlvWriter::new(&mut buf);
452            w.start_list(Tag::Anonymous).unwrap();
453            w.put_uint(Tag::Context(tags::DN_COMMON_NAME), 42).unwrap();
454            w.end_container().unwrap();
455        }
456        let mut r = TlvReader::new(&buf);
457        assert!(matches!(
458            DistinguishedName::read(&mut r),
459            Err(Error::InvalidDnAttributeType(_))
460        ));
461    }
462
463    #[test]
464    fn typed_accessors_return_expected_values() {
465        let dn = DistinguishedName::new(vec![
466            DnAttribute::FabricId(7),
467            DnAttribute::NodeId(42),
468            DnAttribute::CommonName("device-007".into()),
469        ]);
470        assert_eq!(dn.node_id(), Some(42));
471        assert_eq!(dn.fabric_id(), Some(7));
472        assert_eq!(dn.common_name(), Some("device-007"));
473        assert_eq!(dn.rcac_id(), None);
474    }
475}