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(¶ms.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(¶ms.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 ¶ms.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(¶ms.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}