Skip to main content

matter_cert/
operational.rs

1//! Role-aware constructors for Matter operational-PKI certificates
2//! (spec §6.5.5): NOC, ICAC, RCAC.
3//!
4//! Each role has a pinned extension/DN profile the spec mandates; these
5//! constructors bake the profile in so callers cannot accidentally build
6//! a non-conformant operational certificate. Every constructor returns an
7//! [`UnsignedCertificate`] — signing stays external, same two-stage split
8//! as [`crate::builder`].
9//!
10//! Currently implemented: [`rcac`] (Root CA Certificate, spec §6.5.5);
11//! [`icac`] (Intermediate CA Certificate, spec §6.5.5); [`noc`] (Node
12//! Operational Certificate, spec §6.5.5).
13
14use ring::digest;
15use ring::rand::SystemRandom;
16use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
17
18use crate::builder::UnsignedCertificate;
19use crate::certificate::MatterCertificate;
20use crate::error::{Error, Result};
21use crate::extensions::{BasicConstraints, Extensions, KeyIdentifier, KeyUsage};
22use crate::name::{DistinguishedName, DnAttribute};
23use crate::public_key::PublicKey;
24use crate::time::MatterTime;
25
26/// Parameters for [`rcac`].
27///
28/// `#[non_exhaustive]`: future spec-driven RCAC fields (e.g. an optional
29/// `CommonName`) should be addable without breaking existing callers.
30#[non_exhaustive]
31#[derive(Debug, Clone)]
32pub struct RcacParams {
33    /// The Matter Root CA Identifier (subject/issuer DN `RcacId` attribute).
34    pub rcac_id: u64,
35    /// The root's own EC P-256 public key.
36    pub public_key: PublicKey,
37    /// Certificate serial number (1..=20 raw bytes per spec §6.5.1).
38    pub serial: Vec<u8>,
39    /// Start of the validity window.
40    pub not_before: MatterTime,
41    /// End of the validity window (`MatterTime::NO_EXPIRY` for none).
42    pub not_after: MatterTime,
43    /// `BasicConstraints.pathLen` for this root. The RCAC profile
44    /// (spec §6.5.5) recommends `Some(1)`; pass `None` for no constraint.
45    pub path_len: Option<u8>,
46}
47
48impl RcacParams {
49    /// Construct params for [`rcac`] from explicit field values.
50    ///
51    /// `RcacParams` is `#[non_exhaustive]`, so Rust forbids building one
52    /// via struct-literal syntax from outside `matter-cert` — even when
53    /// every field is supplied (`rustc --explain E0639`). This associated
54    /// function is the sanctioned workaround for external callers; code
55    /// inside `matter-cert` can still use struct-literal syntax directly.
56    #[must_use]
57    pub fn new(
58        rcac_id: u64,
59        public_key: PublicKey,
60        serial: Vec<u8>,
61        not_before: MatterTime,
62        not_after: MatterTime,
63        path_len: Option<u8>,
64    ) -> Self {
65        Self {
66            rcac_id,
67            public_key,
68            serial,
69            not_before,
70            not_after,
71            path_len,
72        }
73    }
74}
75
76/// Parameters for [`icac`].
77///
78/// `#[non_exhaustive]`: future spec-driven ICAC fields should be addable
79/// without breaking existing callers.
80#[non_exhaustive]
81#[derive(Debug, Clone)]
82pub struct IcacParams {
83    /// The Matter Intermediate CA Identifier (subject DN `IcacId` attribute).
84    pub icac_id: u64,
85    /// The issuing RCAC's Distinguished Name (this ICAC's issuer DN).
86    pub issuer: DistinguishedName,
87    /// The issuing RCAC's Subject Key Identifier (this ICAC's AKID).
88    pub issuer_skid: KeyIdentifier,
89    /// The intermediate CA's own EC P-256 public key.
90    pub public_key: PublicKey,
91    /// Certificate serial number (1..=20 raw bytes per spec §6.5.1).
92    pub serial: Vec<u8>,
93    /// Start of the validity window.
94    pub not_before: MatterTime,
95    /// End of the validity window (`MatterTime::NO_EXPIRY` for none).
96    pub not_after: MatterTime,
97}
98
99impl IcacParams {
100    /// Construct params for [`icac`] from explicit field values.
101    ///
102    /// `IcacParams` is `#[non_exhaustive]`, so Rust forbids struct-literal
103    /// construction from outside `matter-cert` even when every field is
104    /// supplied (`rustc --explain E0639`); this is the sanctioned constructor
105    /// for external callers.
106    #[must_use]
107    pub fn new(
108        icac_id: u64,
109        issuer: DistinguishedName,
110        issuer_skid: KeyIdentifier,
111        public_key: PublicKey,
112        serial: Vec<u8>,
113        not_before: MatterTime,
114        not_after: MatterTime,
115    ) -> Self {
116        Self {
117            icac_id,
118            issuer,
119            issuer_skid,
120            public_key,
121            serial,
122            not_before,
123            not_after,
124        }
125    }
126}
127
128/// Parameters for [`noc`].
129///
130/// `#[non_exhaustive]`: future spec-driven NOC fields should be addable
131/// without breaking existing callers.
132#[non_exhaustive]
133#[derive(Debug, Clone)]
134pub struct NocParams {
135    /// The Fabric ID this NOC belongs to (subject DN `FabricId` attribute).
136    pub fabric_id: u64,
137    /// The Node ID assigned to this NOC's holder (subject DN `NodeId`
138    /// attribute).
139    pub node_id: u64,
140    /// CASE Authenticated Tags (CATs). Each entry becomes its own
141    /// `DnAttribute::CaseAuthenticatedTag` in the subject DN, appended in
142    /// order after `FabricId`/`NodeId` (spec §6.5.6 Table 71).
143    pub case_authenticated_tags: Vec<u32>,
144    /// The issuing CA's (RCAC's or ICAC's) Distinguished Name (this NOC's
145    /// issuer DN).
146    pub issuer: DistinguishedName,
147    /// The issuing CA's Subject Key Identifier (this NOC's AKID).
148    pub issuer_skid: KeyIdentifier,
149    /// The NOC holder's own EC P-256 public key.
150    pub public_key: PublicKey,
151    /// Certificate serial number (1..=20 raw bytes per spec §6.5.1).
152    pub serial: Vec<u8>,
153    /// Start of the validity window.
154    pub not_before: MatterTime,
155    /// End of the validity window (`MatterTime::NO_EXPIRY` for none).
156    pub not_after: MatterTime,
157}
158
159impl NocParams {
160    /// Construct params for [`noc`] from explicit field values.
161    ///
162    /// `NocParams` is `#[non_exhaustive]`, so Rust forbids struct-literal
163    /// construction from outside `matter-cert` even when every field is
164    /// supplied (`rustc --explain E0639`); this is the sanctioned constructor
165    /// for external callers.
166    #[must_use]
167    #[allow(clippy::too_many_arguments)] // A NOC's identity is intrinsically this many fields.
168    pub fn new(
169        fabric_id: u64,
170        node_id: u64,
171        case_authenticated_tags: Vec<u32>,
172        issuer: DistinguishedName,
173        issuer_skid: KeyIdentifier,
174        public_key: PublicKey,
175        serial: Vec<u8>,
176        not_before: MatterTime,
177        not_after: MatterTime,
178    ) -> Self {
179        Self {
180            fabric_id,
181            node_id,
182            case_authenticated_tags,
183            issuer,
184            issuer_skid,
185            public_key,
186            serial,
187            not_before,
188            not_after,
189        }
190    }
191}
192
193/// Extended-key-usage OID arc values for the NOC profile (spec §6.5.4):
194/// id-kp-clientAuth (1.3.6.1.5.5.7.3.2) and id-kp-serverAuth
195/// (1.3.6.1.5.5.7.3.1). Client listed first — matches
196/// `matter-commissioning::noc::issuer::issue_noc`'s constants and the
197/// order matter.js's `Certificate.asUnsignedDer()` emits.
198const EKU_CLIENT_AUTH: u32 = 2;
199const EKU_SERVER_AUTH: u32 = 1;
200
201/// Compute a Subject/Authority Key Identifier from a public key: SHA-1 over
202/// the 64-byte `X || Y` of the SEC1-uncompressed point, **excluding the
203/// leading `0x04` prefix byte**.
204///
205/// This is the Matter §6.5.4 convention: matter.js hashes the bare `X || Y`,
206/// and it is byte-parity-pinned by `matter-commissioning`'s operational-cert
207/// issuers (`noc::fabric` RCAC + `noc::issuer` NOC, the M6.3.3 gate). It
208/// deliberately differs from RFC 5280 §4.2.1.2 method (1), which hashes the
209/// whole `subjectPublicKey` BIT STRING (i.e. including the `0x04`). SKID/AKID
210/// are identifiers, not a security hash, so SHA-1 is correct and intended
211/// here.
212fn skid_from_spki(pk: &PublicKey) -> KeyIdentifier {
213    let hash = digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, &pk.as_bytes()[1..]);
214    // digest::SHA1_FOR_LEGACY_USE_ONLY always yields exactly 20 bytes, so
215    // this slice-to-array conversion cannot fail.
216    let mut out = [0u8; 20];
217    out.copy_from_slice(hash.as_ref());
218    KeyIdentifier(out)
219}
220
221/// Build an unsigned self-signed Root CA Certificate (RCAC, spec §6.5.5).
222///
223/// Pins the RCAC profile from spec §6.5.5 / §6.5.4:
224/// - subject DN = issuer DN = `RcacId(params.rcac_id)` (self-signed)
225/// - `BasicConstraints { cA: true, pathLen: params.path_len }`, critical
226/// - `KeyUsage { keyCertSign, cRLSign }`, critical
227/// - `SubjectKeyIdentifier` = SHA-1(SPKI); `AuthorityKeyIdentifier` == SKID
228///   (self-signed: the root is its own authority)
229///
230/// # Errors
231///
232/// Returns [`crate::Error::FieldValueOutOfRange`] if `params.serial` is
233/// empty or longer than the 20-byte maximum (spec §6.5.1). All other
234/// [`RcacParams`] fields are structurally valid by construction, so no
235/// other builder error is reachable here.
236pub fn rcac(params: RcacParams) -> Result<UnsignedCertificate> {
237    let subject = DistinguishedName::new(vec![DnAttribute::RcacId(params.rcac_id)]);
238    let issuer = subject.clone();
239
240    let skid = skid_from_spki(&params.public_key);
241
242    let extensions = Extensions::builder()
243        .basic_constraints(Some(BasicConstraints::new(true, params.path_len)))
244        .key_usage(Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN))
245        .subject_key_identifier(Some(skid))
246        .authority_key_identifier(Some(skid))
247        .build();
248
249    MatterCertificate::builder()
250        .serial(params.serial)
251        .issuer(issuer)
252        .subject(subject)
253        .validity(params.not_before, params.not_after)
254        .public_key(params.public_key)
255        .extensions(extensions)
256        .build_unsigned()
257}
258
259/// Build an unsigned Intermediate CA Certificate (ICAC, spec §6.5.5).
260///
261/// Pins the ICAC profile from spec §6.5.5 / §6.5.4:
262/// - subject DN = `IcacId(params.icac_id)`; issuer DN = `params.issuer`
263///   (the RCAC's DN)
264/// - `BasicConstraints { cA: true, pathLen: Some(0) }`, critical
265/// - `KeyUsage { keyCertSign, cRLSign }`, critical
266/// - `SubjectKeyIdentifier` = SHA-1(SPKI) of this ICAC's own public key;
267///   `AuthorityKeyIdentifier` = `params.issuer_skid` (the RCAC's SKID)
268///
269/// # Errors
270///
271/// Returns [`crate::Error::FieldValueOutOfRange`] if `params.serial` is
272/// empty or longer than the 20-byte maximum (spec §6.5.1). All other
273/// [`IcacParams`] fields are structurally valid by construction, so no
274/// other builder error is reachable here.
275pub fn icac(params: IcacParams) -> Result<UnsignedCertificate> {
276    let subject = DistinguishedName::new(vec![DnAttribute::IcacId(params.icac_id)]);
277
278    let skid = skid_from_spki(&params.public_key);
279
280    let extensions = Extensions::builder()
281        .basic_constraints(Some(BasicConstraints::new(true, Some(0))))
282        .key_usage(Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN))
283        .subject_key_identifier(Some(skid))
284        .authority_key_identifier(Some(params.issuer_skid))
285        .build();
286
287    MatterCertificate::builder()
288        .serial(params.serial)
289        .issuer(params.issuer)
290        .subject(subject)
291        .validity(params.not_before, params.not_after)
292        .public_key(params.public_key)
293        .extensions(extensions)
294        .build_unsigned()
295}
296
297/// Build an unsigned Node Operational Certificate (NOC, spec §6.5.5).
298///
299/// Pins the NOC profile from spec §6.5.5 / §6.5.4:
300/// - subject DN = `FabricId(params.fabric_id)`, then `NodeId(params.node_id)`,
301///   then one `CaseAuthenticatedTag` per entry of
302///   `params.case_authenticated_tags` (in order); issuer DN =
303///   `params.issuer` (the RCAC's or ICAC's DN)
304/// - `BasicConstraints { cA: false }`
305/// - `KeyUsage { digitalSignature }`
306/// - `ExtendedKeyUsage = [id-kp-clientAuth, id-kp-serverAuth]`
307/// - `SubjectKeyIdentifier` = SHA-1 of this NOC's own public key (the Matter
308///   §6.5.4 64-byte `X || Y` convention); `AuthorityKeyIdentifier` =
309///   `params.issuer_skid` (the issuing CA's SKID)
310///
311/// Byte-parity: the SKID matches
312/// `matter-commissioning::noc::issuer::issue_noc`'s existing, wire-tested
313/// computation (both go through the same §6.5.4 convention), so a later task
314/// can refactor `issue_noc` onto this constructor without changing the wire
315/// output.
316///
317/// # Errors
318///
319/// Returns [`crate::Error::FieldValueOutOfRange`] if `params.serial` is
320/// empty or longer than the 20-byte maximum (spec §6.5.1). All other
321/// [`NocParams`] fields are structurally valid by construction, so no
322/// other builder error is reachable here.
323pub fn noc(params: NocParams) -> Result<UnsignedCertificate> {
324    let mut subject_attrs: Vec<DnAttribute> =
325        Vec::with_capacity(2 + params.case_authenticated_tags.len());
326    subject_attrs.push(DnAttribute::FabricId(params.fabric_id));
327    subject_attrs.push(DnAttribute::NodeId(params.node_id));
328    for cat in &params.case_authenticated_tags {
329        subject_attrs.push(DnAttribute::CaseAuthenticatedTag(*cat));
330    }
331    let subject = DistinguishedName::new(subject_attrs);
332
333    let skid = skid_from_spki(&params.public_key);
334
335    let extensions = Extensions::builder()
336        .basic_constraints(Some(BasicConstraints::new(false, None)))
337        .key_usage(Some(KeyUsage::DIGITAL_SIGNATURE))
338        .extended_key_usage(Some(vec![EKU_CLIENT_AUTH, EKU_SERVER_AUTH]))
339        .subject_key_identifier(Some(skid))
340        .authority_key_identifier(Some(params.issuer_skid))
341        .build();
342
343    MatterCertificate::builder()
344        .serial(params.serial)
345        .issuer(params.issuer)
346        .subject(subject)
347        .validity(params.not_before, params.not_after)
348        .public_key(params.public_key)
349        .extensions(extensions)
350        .build_unsigned()
351}
352
353/// Sign `unsigned` with `issuer_pkcs8` (a PKCS#8 DER-encoded P-256 private
354/// key) and return the assembled, signed [`MatterCertificate`].
355///
356/// Convenience wrapper around the two-stage `UnsignedCertificate` flow
357/// (see [`crate::builder`]) for the common case of signing with an
358/// in-process `ring` key: computes [`UnsignedCertificate::tbs_der`], signs
359/// it with `ring`'s `ECDSA_P256_SHA256_FIXED_SIGNING` (whose output is
360/// exactly the 64-byte raw `r || s` form the Matter wire format uses — no
361/// ASN.1 DER wrapping, unlike `ECDSA_P256_SHA256_ASN1_SIGNING`), then calls
362/// [`UnsignedCertificate::assemble`].
363///
364/// For a self-signed certificate (e.g. an [`rcac`]), pass the same key's
365/// PKCS#8 bytes as `issuer_pkcs8`.
366///
367/// # Errors
368///
369/// Returns [`Error::SigningFailed`] if `issuer_pkcs8` is not a valid P-256
370/// PKCS#8 key, or if `ring` rejects the signing request. Returns
371/// [`Error::WrongSignatureLength`] in the (practically unreachable) case
372/// that `ring`'s fixed-length signer emits a signature that is not exactly
373/// 64 bytes. Also returns any error [`UnsignedCertificate::tbs_der`] would
374/// return on conversion failure.
375pub fn sign_with_ring(
376    unsigned: UnsignedCertificate,
377    issuer_pkcs8: &[u8],
378) -> Result<MatterCertificate> {
379    let tbs = unsigned.tbs_der()?;
380
381    let rng = SystemRandom::new();
382    let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, issuer_pkcs8, &rng)
383        .map_err(|_| Error::SigningFailed("issuer PKCS#8 key rejected by ring"))?;
384    let sig = key_pair
385        .sign(&rng, &tbs)
386        .map_err(|_| Error::SigningFailed("ring ECDSA signing failed"))?;
387
388    let sig_bytes = sig.as_ref();
389    if sig_bytes.len() != 64 {
390        return Err(Error::WrongSignatureLength(sig_bytes.len()));
391    }
392    let mut sig_arr = [0u8; 64];
393    sig_arr.copy_from_slice(sig_bytes);
394
395    Ok(unsigned.assemble(sig_arr))
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
400mod tests {
401    use super::*;
402    use crate::{MatterTime, PublicKey};
403
404    fn spki() -> PublicKey {
405        PublicKey::new([0x04; 65]).unwrap()
406    }
407
408    #[test]
409    fn rcac_has_the_expected_profile() {
410        let unsigned = rcac(RcacParams {
411            rcac_id: 1,
412            public_key: spki(),
413            serial: vec![0x01],
414            not_before: MatterTime::from_unix_secs(1_700_000_000),
415            not_after: MatterTime::NO_EXPIRY,
416            path_len: Some(1),
417        })
418        .unwrap();
419        let ext = unsigned.extensions();
420        let bc = ext.basic_constraints.unwrap();
421        assert!(bc.is_ca && bc.path_len_constraint == Some(1));
422        assert_eq!(
423            ext.key_usage,
424            Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN)
425        );
426        assert!(ext.subject_key_identifier.is_some());
427        // Self-signed: AKID == SKID; issuer DN == subject DN (RcacId=1).
428        assert_eq!(ext.authority_key_identifier, ext.subject_key_identifier);
429        assert_eq!(unsigned.subject().rcac_id(), Some(1));
430        assert_eq!(unsigned.issuer().rcac_id(), Some(1));
431    }
432
433    #[test]
434    fn icac_has_the_expected_profile() {
435        let issuer_dn = DistinguishedName::new(vec![DnAttribute::RcacId(1)]);
436        let issuer_skid = skid_from_spki(&spki());
437
438        let unsigned = icac(IcacParams {
439            icac_id: 2,
440            issuer: issuer_dn.clone(),
441            issuer_skid,
442            public_key: spki(),
443            serial: vec![0x02],
444            not_before: MatterTime::from_unix_secs(1_700_000_000),
445            not_after: MatterTime::NO_EXPIRY,
446        })
447        .unwrap();
448
449        let ext = unsigned.extensions();
450        let bc = ext.basic_constraints.unwrap();
451        assert!(bc.is_ca && bc.path_len_constraint == Some(0));
452        assert_eq!(
453            ext.key_usage,
454            Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN)
455        );
456        assert!(ext.subject_key_identifier.is_some());
457        assert_eq!(ext.authority_key_identifier, Some(issuer_skid));
458        assert_eq!(unsigned.subject().icac_id(), Some(2));
459        assert_eq!(unsigned.issuer().rcac_id(), Some(1));
460    }
461
462    #[test]
463    fn noc_has_the_expected_profile() {
464        let issuer_dn = DistinguishedName::new(vec![DnAttribute::IcacId(2)]);
465        let issuer_skid = skid_from_spki(&spki());
466
467        let unsigned = noc(NocParams {
468            fabric_id: 7,
469            node_id: 0xDEAD_BEEF_CAFE_BABE,
470            case_authenticated_tags: vec![0x0001_0002, 0x0003_0004],
471            issuer: issuer_dn.clone(),
472            issuer_skid,
473            public_key: spki(),
474            serial: vec![0x03],
475            not_before: MatterTime::from_unix_secs(1_700_000_000),
476            not_after: MatterTime::NO_EXPIRY,
477        })
478        .unwrap();
479
480        let ext = unsigned.extensions();
481        let bc = ext.basic_constraints.unwrap();
482        assert!(!bc.is_ca);
483        assert_eq!(ext.key_usage, Some(KeyUsage::DIGITAL_SIGNATURE));
484        assert_eq!(ext.extended_key_usage, Some(vec![2, 1]));
485        assert!(ext.subject_key_identifier.is_some());
486        assert_eq!(ext.authority_key_identifier, Some(issuer_skid));
487
488        // Subject DN attribute order: FabricId, NodeId, then CATs in order.
489        assert_eq!(
490            unsigned.subject().iter().cloned().collect::<Vec<_>>(),
491            vec![
492                DnAttribute::FabricId(7),
493                DnAttribute::NodeId(0xDEAD_BEEF_CAFE_BABE),
494                DnAttribute::CaseAuthenticatedTag(0x0001_0002),
495                DnAttribute::CaseAuthenticatedTag(0x0003_0004),
496            ]
497        );
498        assert_eq!(unsigned.issuer(), &issuer_dn);
499    }
500
501    #[test]
502    fn skid_uses_matter_64byte_convention() {
503        // Byte-parity guardrail (Matter §6.5.4): a NOC's SKID must hash the
504        // 64-byte X||Y point (EXCLUDING the 0x04 prefix), matching
505        // `matter-commissioning::noc::issuer::issue_noc` /
506        // `noc::fabric`'s wire-tested computation. `skid_from_spki` (shared by
507        // rcac/icac/noc) must use the same convention — if it regresses to
508        // hashing the full 65-byte point, every operational cert's SKID
509        // silently diverges from matter.js/chip.
510        let pk = spki();
511        let unsigned = noc(NocParams {
512            fabric_id: 1,
513            node_id: 2,
514            case_authenticated_tags: vec![],
515            issuer: DistinguishedName::new(vec![DnAttribute::RcacId(1)]),
516            issuer_skid: skid_from_spki(&pk),
517            public_key: pk.clone(),
518            serial: vec![0x04],
519            not_before: MatterTime::from_unix_secs(1_700_000_000),
520            not_after: MatterTime::NO_EXPIRY,
521        })
522        .unwrap();
523
524        let expected = {
525            let hash = digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, &pk.as_bytes()[1..]);
526            let mut arr = [0u8; 20];
527            arr.copy_from_slice(hash.as_ref());
528            KeyIdentifier(arr)
529        };
530        // The NOC's SKID is the 64-byte-convention hash...
531        assert_eq!(unsigned.extensions().subject_key_identifier, Some(expected));
532        // ...and `skid_from_spki` produces exactly that (all roles consistent).
533        assert_eq!(expected, skid_from_spki(&pk));
534        // Guard the actual regression: hashing the full 65-byte point (the
535        // old bug) is a DIFFERENT value.
536        let full = {
537            let hash = digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, pk.as_bytes());
538            let mut arr = [0u8; 20];
539            arr.copy_from_slice(hash.as_ref());
540            KeyIdentifier(arr)
541        };
542        assert_ne!(expected, full);
543    }
544}