matter_commissioning/attestation/cd/verifier.rs
1//! Certification Declaration verifier implementation.
2//!
3//! The verifier performs five checks in order:
4//!
5//! 1. CMS `SignedData` DER parse via the `cms` crate.
6//! 2. Structural validation: a single `SignerInfo`, attached
7//! encapsulated content.
8//! 3. ECDSA-P256 / SHA-256 signature verification against each
9//! trusted root's public key; accept on first match.
10//! 4. Decode the inner CD TLV via `parse_inner_cd_tlv` (M6.4.3 T30
11//! fills the helper).
12//! 5. Cross-check declared VID + PID against the expected pair
13//! supplied by the caller (typically sourced from the verified DAC
14//! subject).
15//!
16//! The trust store ([`CdSigningRoots`]) holds SEC1-uncompressed P-256
17//! public keys for each trusted CSA signing root. Production callers
18//! build the store via [`CdSigningRoots::from_pem`]; tests and
19//! examples use the bundled CSA-test root via
20//! [`CdSigningRoots::with_example_device_roots`].
21
22#![forbid(unsafe_code)]
23
24use crate::attestation::{AttestationError, ProductId, VendorId};
25
26/// Bundled PEM-encoded `SubjectPublicKeyInfo` for the synthetic
27/// CSA-test CD signing root generated by `xtask capture-cd`. The
28/// matching private key lives in `test-vectors/commissioning/cd/` and
29/// signs the fixture CDs the verifier tests consume.
30const CSA_TEST_CD_SIGNING_ROOT_PEM: &[u8] =
31 include_bytes!("./csa_cd_signing_roots/csa-test-cd-signing-root.pem");
32
33/// chip's real **test** CD signing authority (X.509 DER). Signs the CDs
34/// of chip's example/test devices that use the test signer (e.g.
35/// `Chip-Test-CD-FFF2-8001`). Vendored from connectedhomeip
36/// `credentials/test/certification-declaration/`.
37const CHIP_TEST_CD_SIGNING_CERT_DER: &[u8] =
38 include_bytes!("./csa_cd_signing_roots/Chip-Test-CD-Signing-Cert.der");
39
40/// CSA **production** "CD Signing Key 001" (X.509 DER). Signs the
41/// VID=0xFFF1 CD every `CONFIG_EXAMPLE_DAC_PROVIDER` device serves,
42/// including the esp-matter ESP32-C6. Vendored from connectedhomeip
43/// `credentials/production/cd-certs/`.
44const CSA_CD_SIGNING_KEY_001_DER: &[u8] =
45 include_bytes!("./csa_cd_signing_roots/CSA-CD-Signing-Key-001.der");
46
47/// Trusted CSA Certification Declaration signing roots.
48///
49/// Built from production roots via [`Self::from_cert_der`] (X.509 CD
50/// signing certificates, as published by the CSA DCL) or
51/// [`Self::from_pem`] (bare `SubjectPublicKeyInfo` PEMs), or seeded with
52/// the bundled synthetic CSA-test root via
53/// [`Self::with_example_device_roots`].
54///
55/// Internally stores each trusted root as a SEC1-uncompressed P-256
56/// public key (65 bytes: `0x04 || X || Y`) so signature verification
57/// can call `ring::signature::UnparsedPublicKey` directly without
58/// re-parsing.
59#[derive(Debug, Clone)]
60pub struct CdSigningRoots {
61 /// SEC1-uncompressed P-256 public keys (65 bytes each) for each
62 /// trusted root.
63 public_keys: Vec<Vec<u8>>,
64}
65
66impl CdSigningRoots {
67 /// Build a trust store seeded with the CD signing roots that verify
68 /// CSA **test / example** devices and the hermetic loopback:
69 ///
70 /// - the bundled **synthetic** root — its private half signs the
71 /// loopback and fixture CDs the verifier tests consume;
72 /// - chip's real **test** CD signing authority; and
73 /// - CSA **production** "CD Signing Key 001" — the key that signs the
74 /// VID=0xFFF1 CD every `CONFIG_EXAMPLE_DAC_PROVIDER` device serves,
75 /// including the esp-matter ESP32-C6. (chip's own
76 /// `DefaultDeviceAttestationVerifier` trusts the test *and*
77 /// production keys; trusting only the test key rejects the C6, which
78 /// cost a live commission to learn — see `chip_cd_vector.rs`.)
79 ///
80 /// This verifies test / dev / example devices — **not** the full set
81 /// of CSA production-certified products, which may present CDs signed
82 /// by other CSA production keys. A commissioner for arbitrary
83 /// certified devices loads the whole CSA root set via
84 /// [`Self::from_cert_der`] / [`Self::from_pem`] (e.g.
85 /// `matter_controller::AttestationTrust::from_dirs`).
86 ///
87 /// Each bundled root is a compile-time constant; one that fails to
88 /// parse is skipped rather than panicking, and the verifier then
89 /// rejects any CD that needed it with
90 /// [`AttestationError::CertificationDeclarationSignatureInvalid`].
91 #[must_use]
92 pub fn with_example_device_roots() -> Self {
93 let mut public_keys = Vec::with_capacity(3);
94 if let Ok(pk) = parse_pem_public_key(CSA_TEST_CD_SIGNING_ROOT_PEM) {
95 public_keys.push(pk);
96 }
97 for der in [CHIP_TEST_CD_SIGNING_CERT_DER, CSA_CD_SIGNING_KEY_001_DER] {
98 if let Some(pk) = cert_der_public_key(der) {
99 public_keys.push(pk);
100 }
101 }
102 Self { public_keys }
103 }
104
105 /// Build a trust store from PEM-encoded P-256
106 /// `SubjectPublicKeyInfo` blobs (one per trusted CSA signing
107 /// root).
108 ///
109 /// # Errors
110 ///
111 /// Returns [`AttestationError::CertificationDeclarationMalformed`]
112 /// if any input fails to parse. An empty slice returns an empty
113 /// trust store.
114 pub fn from_pem(pems: &[&[u8]]) -> Result<Self, AttestationError> {
115 let mut public_keys = Vec::with_capacity(pems.len());
116 for raw in pems {
117 let pk = parse_pem_public_key(raw)?;
118 public_keys.push(pk);
119 }
120 Ok(Self { public_keys })
121 }
122
123 /// Build a trust store from X.509 **certificate** DER blobs (one per
124 /// trusted CSA CD signing root), extracting each certificate's P-256
125 /// subject public key.
126 ///
127 /// This is the ingestion path for real-world CD signing roots: the CSA
128 /// Distributed Compliance Ledger — and the `connectedhomeip`
129 /// `credentials/production/cd-certs/` mirror of it — publish the roots as
130 /// X.509 certificates, not as the bare `SubjectPublicKeyInfo` PEMs that
131 /// [`Self::from_pem`] consumes. There are several distinct CSA CD signing
132 /// keys, so a real commissioner typically loads the whole directory.
133 ///
134 /// The certificate is treated purely as a trust anchor: only its subject
135 /// public key is extracted. No signature, validity-window, or chain checks
136 /// are performed — the operator vouches for the roots by supplying them
137 /// (exactly as [`Self::from_pem`] trusts the keys it is given).
138 ///
139 /// # Errors
140 ///
141 /// Returns [`AttestationError::CertificationDeclarationMalformed`] if any
142 /// input fails to parse as an X.509 certificate, or does not carry a
143 /// 65-byte SEC1-uncompressed P-256 public key.
144 pub fn from_cert_der(certs: &[&[u8]]) -> Result<Self, AttestationError> {
145 let mut public_keys = Vec::with_capacity(certs.len());
146 for der in certs {
147 // CD signatures are ECDSA-P256; the trust root must carry a
148 // SEC1-uncompressed P-256 point (`0x04` || X || Y, 65 bytes).
149 let pk = cert_der_public_key(der)
150 .ok_or(AttestationError::CertificationDeclarationMalformed)?;
151 public_keys.push(pk);
152 }
153 Ok(Self { public_keys })
154 }
155
156 /// Number of trusted roots in the store.
157 #[must_use]
158 pub fn len(&self) -> usize {
159 self.public_keys.len()
160 }
161
162 /// Returns `true` if no trusted roots have been loaded.
163 #[must_use]
164 pub fn is_empty(&self) -> bool {
165 self.public_keys.is_empty()
166 }
167
168 /// Internal accessor — borrow the raw 65-byte SEC1 uncompressed
169 /// public-key bytes for each trusted root, for the verifier's
170 /// signature-check loop.
171 fn keys(&self) -> &[Vec<u8>] {
172 &self.public_keys
173 }
174}
175
176/// Verify a Certification Declaration against a trust store and an
177/// expected VID / PID pair, without enforcing the CD's
178/// `authorized_paa_list`.
179///
180/// Equivalent to [`verify_certification_declaration_with_paa`] with no
181/// device PAA `SubjectKeyIdentifier`: a CD carrying an `authorized_paa_list`
182/// (tag 11) is **not** enforced. Prefer the `_with_paa` form during
183/// commissioning, where the anchoring PAA's SKID is known — otherwise a
184/// device may present a CD that restricts itself to PAAs it does not
185/// actually chain to.
186///
187/// # Errors
188///
189/// See [`verify_certification_declaration_with_paa`].
190#[allow(
191 clippy::similar_names,
192 reason = "expected_vid/expected_pid mirror the crate-wide VendorId/ProductId vocabulary"
193)]
194pub fn verify_certification_declaration(
195 cd_bytes: &[u8],
196 expected_vid: VendorId,
197 expected_pid: ProductId,
198 trust: &CdSigningRoots,
199) -> Result<(), AttestationError> {
200 verify_certification_declaration_with_paa(cd_bytes, expected_vid, expected_pid, trust, None)
201}
202
203/// Verify a Certification Declaration extracted from
204/// `attestation_elements` (Matter Core Spec §6.3.1) against a trust
205/// store, an expected VID / PID pair, and — when supplied — the
206/// `SubjectKeyIdentifier` of the PAA that anchored the device's DAC chain.
207///
208/// Performs six checks in order:
209///
210/// 1. CMS `SignedData` DER parse via the `cms` crate.
211/// 2. Structural validation: a single `SignerInfo`, attached
212/// encapsulated content.
213/// 3. ECDSA-P256 / SHA-256 signature verification against each
214/// trusted root in `trust`; accept on first match.
215/// 4. Decode the inner CD TLV.
216/// 5. Cross-check declared VID + PID against `expected_vid` /
217/// `expected_pid` (sourced from the verified DAC chain by the
218/// caller). Per Matter Core Spec §6.2.3, when the CD carries both
219/// `dac_origin_vendor_id` (tag 9) and `dac_origin_product_id`
220/// (tag 10), those override fields are compared instead of the CD's
221/// own `vendor_id` / `product_id_array`.
222/// 6. If the CD carries an `authorized_paa_list` (tag 11), require
223/// `device_paa_skid` to be one of its entries (Matter §6.2.3, chip
224/// `DefaultDeviceAttestationVerifier.cpp:738`). Passing `None` for
225/// `device_paa_skid` skips this check only when the CD omits tag 11;
226/// a CD that *does* carry tag 11 is rejected when no SKID is supplied.
227///
228/// # Errors
229///
230/// - [`AttestationError::CertificationDeclarationMalformed`] — the
231/// CMS DER failed to parse or did not match the expected shape.
232/// - [`AttestationError::CertificationDeclarationSignatureInvalid`] —
233/// no trusted root accepts the signature.
234/// - [`AttestationError::CertificationDeclarationTlvMalformed`] —
235/// inner CD TLV missing required fields or malformed.
236/// - [`AttestationError::CertificationDeclarationVidMismatch`] —
237/// declared VID does not equal `expected_vid`.
238/// - [`AttestationError::CertificationDeclarationPidMismatch`] —
239/// declared PID list does not contain `expected_pid`.
240/// - [`AttestationError::CertificationDeclarationPaaNotAuthorized`] —
241/// the CD's `authorized_paa_list` does not include the device's PAA
242/// `SubjectKeyIdentifier`.
243#[allow(
244 clippy::similar_names,
245 clippy::too_many_lines,
246 reason = "the `expected_vid`/`expected_pid` pair mirrors the public-API \
247 vocabulary used elsewhere in this crate (VendorId/ProductId); \
248 renaming would obscure intent at the call site. The function is a \
249 single linear six-step CMS+TLV verification, clearer inline than split."
250)]
251pub fn verify_certification_declaration_with_paa(
252 cd_bytes: &[u8],
253 expected_vid: VendorId,
254 expected_pid: ProductId,
255 trust: &CdSigningRoots,
256 device_paa_skid: Option<&[u8]>,
257) -> Result<(), AttestationError> {
258 use cms::content_info::ContentInfo;
259 use cms::signed_data::SignedData;
260 use der::asn1::OctetString;
261 use der::{Decode, DecodeValue, Encode, Header, SliceReader, Tag as DerTag};
262
263 // 1. Parse the outer ContentInfo.
264 let content_info = ContentInfo::from_der(cd_bytes)
265 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
266 // ContentInfo.content is an Any wrapping SignedData; re-encode and
267 // decode to convert.
268 let signed_data_der = content_info
269 .content
270 .to_der()
271 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
272 let signed_data = SignedData::from_der(&signed_data_der)
273 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
274
275 // 2. Validate shape: exactly one SignerInfo and attached content.
276 if signed_data.signer_infos.0.len() != 1 {
277 return Err(AttestationError::CertificationDeclarationMalformed);
278 }
279 let signer = signed_data
280 .signer_infos
281 .0
282 .iter()
283 .next()
284 .ok_or(AttestationError::CertificationDeclarationMalformed)?;
285
286 // 3. Extract the signed content (the inner CD TLV bytes).
287 //
288 // `EncapsulatedContentInfo.econtent` is typed `Option<Any>` where
289 // the Any wraps an OCTET STRING. We decode the OCTET STRING from
290 // the Any's body and copy its bytes — this is the eContent value
291 // (the inner CD TLV) the signer signed.
292 let econtent = signed_data
293 .encap_content_info
294 .econtent
295 .as_ref()
296 .ok_or(AttestationError::CertificationDeclarationMalformed)?;
297 // `Any` does not expose its raw value bytes directly; re-encode it
298 // then peel off the OCTET STRING tag+length to read the inner
299 // bytes via `OctetString::decode_value`.
300 let econtent_der = econtent
301 .to_der()
302 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
303 let mut reader = SliceReader::new(&econtent_der)
304 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
305 let header = Header::decode(&mut reader)
306 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
307 if header.tag != DerTag::OctetString {
308 return Err(AttestationError::CertificationDeclarationMalformed);
309 }
310 let octet_string = OctetString::decode_value(&mut reader, header)
311 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
312 let content_bytes = octet_string.as_bytes().to_vec();
313
314 // 4. Extract the signature bytes.
315 let sig = signer.signature.as_bytes();
316
317 // 5. Verify ECDSA-P256 / SHA-256 against each trusted root.
318 let mut accepted = false;
319 for key in trust.keys() {
320 if verify_ecdsa_p256_sha256(key, &content_bytes, sig).is_ok() {
321 accepted = true;
322 break;
323 }
324 }
325 if !accepted {
326 return Err(AttestationError::CertificationDeclarationSignatureInvalid);
327 }
328
329 // 6. Decode the inner CD TLV and cross-check VID / PID.
330 //
331 // Matter Core Spec §6.2.3: when the CD carries the optional
332 // `dac_origin_vendor_id` (tag 9) AND `dac_origin_product_id`
333 // (tag 10), the commissioner MUST validate the DAC/PAI subject
334 // VID/PID against THOSE origin fields, not the CD's own
335 // `vendor_id` / `product_id_array`. This supports CDs issued for a
336 // PAA/PAI scoped to a different vendor than the device's own VID
337 // (e.g. white-label / contract-manufactured products).
338 //
339 // Decision on partial presence: the spec treats `dac_origin_*` as a
340 // both-or-neither pair. We trigger the override only when BOTH are
341 // present; if exactly one is present we ignore the override and fall
342 // back to the standard fields (the safe, conservative reading — a
343 // half-specified override is not a valid §6.2.3 override).
344 let parsed = parse_inner_cd_tlv(&content_bytes)?;
345 if let (Some(origin_vid), Some(origin_pid)) =
346 (parsed.dac_origin_vendor_id, parsed.dac_origin_product_id)
347 {
348 // Override path: bind the DAC against the dac_origin_* fields.
349 if origin_vid != expected_vid {
350 return Err(AttestationError::CertificationDeclarationVidMismatch {
351 declared: origin_vid,
352 expected: expected_vid,
353 });
354 }
355 if origin_pid != expected_pid {
356 return Err(AttestationError::CertificationDeclarationPidMismatch(
357 expected_pid,
358 ));
359 }
360 } else {
361 // Standard path: bind against vendor_id / product_id_array.
362 if parsed.vendor_id != expected_vid {
363 return Err(AttestationError::CertificationDeclarationVidMismatch {
364 declared: parsed.vendor_id,
365 expected: expected_vid,
366 });
367 }
368 if !parsed.product_ids.contains(&expected_pid) {
369 return Err(AttestationError::CertificationDeclarationPidMismatch(
370 expected_pid,
371 ));
372 }
373 }
374
375 // 7. authorized_paa_list (tag 11): if the CD scopes itself to specific
376 // PAAs, the device's anchoring PAA SKID SHALL be one of them.
377 if !paa_is_authorized(parsed.authorized_paa_list.as_deref(), device_paa_skid) {
378 return Err(AttestationError::CertificationDeclarationPaaNotAuthorized);
379 }
380
381 Ok(())
382}
383
384/// Decoded view of the inner Certification Declaration TLV — the
385/// subset the verifier cross-checks today. T30 fleshes out the parser.
386#[derive(Debug)]
387struct ParsedCd {
388 /// Vendor ID (Matter Core Spec §6.3.1 tag 1).
389 vendor_id: VendorId,
390 /// Product ID list (tag 2 — at least one element required).
391 product_ids: Vec<ProductId>,
392 /// `dac_origin_vendor_id` (Matter Core Spec §6.3.1 tag 9, optional).
393 ///
394 /// When present (together with [`Self::dac_origin_product_id`]), the
395 /// commissioner MUST validate the DAC/PAI subject VID against THIS
396 /// value rather than [`Self::vendor_id`] (Matter Core Spec §6.2.3).
397 dac_origin_vendor_id: Option<VendorId>,
398 /// `dac_origin_product_id` (Matter Core Spec §6.3.1 tag 10, optional).
399 ///
400 /// The PID counterpart to [`Self::dac_origin_vendor_id`]; see its docs.
401 dac_origin_product_id: Option<ProductId>,
402 /// `authorized_paa_list` (Matter Core Spec §6.3.1 tag 11, optional):
403 /// the `SubjectKeyIdentifiers` of the PAAs permitted to anchor this
404 /// device's DAC chain. When present (`Some`, possibly empty), the
405 /// anchoring PAA's SKID MUST be one of these values (Matter §6.2.3);
406 /// each entry is exactly 20 bytes. `None` means the CD imposes no PAA
407 /// constraint.
408 authorized_paa_list: Option<Vec<[u8; 20]>>,
409}
410
411/// Decode the inner CD TLV per Matter Core Spec §6.3.1: an anonymous
412/// outer structure with context-tagged fields including tag 1
413/// (`vendor_id`, u16), tag 2 (`product_id_array`, array of u16), the
414/// optional override fields tag 9 (`dac_origin_vendor_id`, u16) and tag
415/// 10 (`dac_origin_product_id`, u16), and the optional tag 11
416/// (`authorized_paa_list`, array of 20-byte PAA `SubjectKeyIdentifiers`).
417///
418/// All other context-tagged fields (`format_version`, `device_type_id`,
419/// `certificate_id`, `security_level`, `security_information`,
420/// `version_number`, `certification_type`) and any future-extension
421/// fields are forward-compat ignored — the verifier only needs VID + PID
422/// (and the optional `dac_origin_*` overrides + `authorized_paa_list`) for
423/// cross-checking against the DAC subject.
424#[allow(
425 clippy::too_many_lines,
426 reason = "one linear tag-dispatch loop over the CD's context-tagged fields; \
427 splitting the per-tag arms into helpers would scatter the decode logic."
428)]
429fn parse_inner_cd_tlv(tlv: &[u8]) -> Result<ParsedCd, AttestationError> {
430 use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
431
432 let mut reader = TlvReader::new(tlv);
433 match reader
434 .next()
435 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?
436 {
437 Some(Element::ContainerStart {
438 tag: Tag::Anonymous,
439 kind: ContainerKind::Structure,
440 }) => {}
441 _ => return Err(AttestationError::CertificationDeclarationTlvMalformed),
442 }
443
444 let mut vid: Option<VendorId> = None;
445 let mut pids: Vec<ProductId> = Vec::new();
446 let mut origin_vendor: Option<VendorId> = None;
447 let mut origin_product: Option<ProductId> = None;
448 let mut authorized_paa_list: Option<Vec<[u8; 20]>> = None;
449
450 loop {
451 match reader
452 .next()
453 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?
454 {
455 None => return Err(AttestationError::CertificationDeclarationTlvMalformed),
456 Some(Element::ContainerEnd) => break,
457 Some(Element::Scalar {
458 tag: Tag::Context(1),
459 value: Value::Uint(v),
460 }) => {
461 if vid.is_some() {
462 return Err(AttestationError::CertificationDeclarationTlvMalformed);
463 }
464 let v16 = u16::try_from(v)
465 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?;
466 vid = Some(VendorId::new(v16));
467 }
468 Some(Element::ContainerStart {
469 tag: Tag::Context(2),
470 kind: ContainerKind::Array,
471 }) => {
472 if !pids.is_empty() {
473 return Err(AttestationError::CertificationDeclarationTlvMalformed);
474 }
475 loop {
476 match reader
477 .next()
478 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?
479 {
480 None => return Err(AttestationError::CertificationDeclarationTlvMalformed),
481 Some(Element::ContainerEnd) => break,
482 Some(Element::Scalar {
483 tag: Tag::Anonymous,
484 value: Value::Uint(p),
485 }) => {
486 let p16 = u16::try_from(p).map_err(|_| {
487 AttestationError::CertificationDeclarationTlvMalformed
488 })?;
489 pids.push(ProductId::new(p16));
490 }
491 // Inside the array, unknown shapes are a structural error
492 // (the spec says product IDs are u16). But be lenient on
493 // tagged-not-anonymous in case future M6.x extensions land.
494 Some(_) => {}
495 }
496 }
497 }
498 // dac_origin_vendor_id (tag 9) — optional override; see §6.2.3.
499 Some(Element::Scalar {
500 tag: Tag::Context(9),
501 value: Value::Uint(v),
502 }) => {
503 if origin_vendor.is_some() {
504 return Err(AttestationError::CertificationDeclarationTlvMalformed);
505 }
506 let v16 = u16::try_from(v)
507 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?;
508 origin_vendor = Some(VendorId::new(v16));
509 }
510 // dac_origin_product_id (tag 10) — optional override; see §6.2.3.
511 Some(Element::Scalar {
512 tag: Tag::Context(10),
513 value: Value::Uint(p),
514 }) => {
515 if origin_product.is_some() {
516 return Err(AttestationError::CertificationDeclarationTlvMalformed);
517 }
518 let p16 = u16::try_from(p)
519 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?;
520 origin_product = Some(ProductId::new(p16));
521 }
522 // authorized_paa_list (tag 11) — optional; array of 20-byte PAA
523 // SubjectKeyIdentifiers (chip `CertificationDeclaration.cpp:285`).
524 Some(Element::ContainerStart {
525 tag: Tag::Context(11),
526 kind: ContainerKind::Array,
527 }) => {
528 if authorized_paa_list.is_some() {
529 return Err(AttestationError::CertificationDeclarationTlvMalformed);
530 }
531 let mut list: Vec<[u8; 20]> = Vec::new();
532 loop {
533 match reader
534 .next()
535 .map_err(|_| AttestationError::CertificationDeclarationTlvMalformed)?
536 {
537 None => return Err(AttestationError::CertificationDeclarationTlvMalformed),
538 Some(Element::ContainerEnd) => break,
539 Some(Element::Scalar {
540 tag: Tag::Anonymous,
541 value: Value::Bytes(b),
542 }) => {
543 // chip requires each entry to be exactly a 20-byte
544 // key identifier; anything else is a structural error.
545 let skid: [u8; 20] = b.as_slice().try_into().map_err(|_| {
546 AttestationError::CertificationDeclarationTlvMalformed
547 })?;
548 list.push(skid);
549 }
550 Some(_) => {
551 return Err(AttestationError::CertificationDeclarationTlvMalformed)
552 }
553 }
554 }
555 authorized_paa_list = Some(list);
556 }
557 // Forward-compat: ignore other context-tagged scalars / containers.
558 Some(_) => {}
559 }
560 }
561
562 let vendor_id = vid.ok_or(AttestationError::CertificationDeclarationTlvMalformed)?;
563 if pids.is_empty() {
564 // product_id_array required to be non-empty per spec §6.3.1.
565 return Err(AttestationError::CertificationDeclarationTlvMalformed);
566 }
567 Ok(ParsedCd {
568 vendor_id,
569 product_ids: pids,
570 dac_origin_vendor_id: origin_vendor,
571 dac_origin_product_id: origin_product,
572 authorized_paa_list,
573 })
574}
575
576/// Whether a device's anchoring-PAA `SubjectKeyIdentifier` is authorized by
577/// a CD's `authorized_paa_list` (Matter §6.2.3, chip
578/// `DefaultDeviceAttestationVerifier.cpp:738`).
579///
580/// - `None` list → the CD imposes no PAA constraint → authorized.
581/// - `Some(list)` → `device_paa_skid` must be present and byte-equal to
582/// one of the 20-byte entries. A missing device SKID, or a SKID not in
583/// the list, is unauthorized.
584fn paa_is_authorized(list: Option<&[[u8; 20]]>, device_paa_skid: Option<&[u8]>) -> bool {
585 match list {
586 None => true,
587 Some(entries) => match device_paa_skid {
588 Some(skid) => entries.iter().any(|e| e.as_slice() == skid),
589 None => false,
590 },
591 }
592}
593
594/// Parse a PEM-encoded `SubjectPublicKeyInfo` for a P-256 public key
595/// and return the SEC1 uncompressed point (65 bytes:
596/// `0x04 || X || Y`) suitable for `ring`'s
597/// `UnparsedPublicKey<ECDSA_P256_SHA256_FIXED>`.
598///
599/// Strips the `-----BEGIN PUBLIC KEY-----` / `-----END PUBLIC KEY-----`
600/// armor, base64-decodes the body, and slices the trailing 65 bytes of
601/// the DER (the SEC1 uncompressed point inside the SPKI's `BIT
602/// STRING`). The point's `0x04` marker byte is checked here; ring's
603/// `UnparsedPublicKey` rejects any malformed point at signature-verify
604/// time as a second line of defense.
605/// Extract the 65-byte SEC1-uncompressed P-256 subject public key from
606/// an X.509 certificate DER, or `None` if it does not parse as X.509 or
607/// does not carry a P-256 point. Used to ingest CD signing roots that
608/// are published as certificates (CSA DCL / `connectedhomeip`
609/// `credentials/`) rather than as bare `SubjectPublicKeyInfo` PEMs.
610fn cert_der_public_key(der: &[u8]) -> Option<Vec<u8>> {
611 use x509_parser::prelude::{FromDer, X509Certificate};
612
613 let (_, cert) = X509Certificate::from_der(der).ok()?;
614 let pk = cert.public_key().subject_public_key.data.as_ref().to_vec();
615 (pk.len() == 65 && pk[0] == 0x04).then_some(pk)
616}
617
618fn parse_pem_public_key(pem: &[u8]) -> Result<Vec<u8>, AttestationError> {
619 use base64::Engine;
620
621 const HEADER: &str = "-----BEGIN PUBLIC KEY-----";
622 const FOOTER: &str = "-----END PUBLIC KEY-----";
623
624 let pem_str = std::str::from_utf8(pem)
625 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
626
627 let header_start = pem_str
628 .find(HEADER)
629 .ok_or(AttestationError::CertificationDeclarationMalformed)?;
630 let body_start = header_start + HEADER.len();
631 let footer_start = pem_str
632 .find(FOOTER)
633 .ok_or(AttestationError::CertificationDeclarationMalformed)?;
634 if footer_start <= body_start {
635 return Err(AttestationError::CertificationDeclarationMalformed);
636 }
637
638 // Strip all whitespace from the base64 body.
639 let body: String = pem_str[body_start..footer_start]
640 .chars()
641 .filter(|c| !c.is_whitespace())
642 .collect();
643
644 let der = base64::engine::general_purpose::STANDARD
645 .decode(body.as_bytes())
646 .map_err(|_| AttestationError::CertificationDeclarationMalformed)?;
647
648 // Extract the SEC1 uncompressed point from the SubjectPublicKeyInfo.
649 // The structure for a P-256 SPKI is exactly 91 bytes:
650 // 30 59 SEQUENCE (89)
651 // 30 13 SEQUENCE (19)
652 // 06 07 2A 86 48 CE 3D 02 01 OID ecPublicKey
653 // 06 08 2A 86 48 CE 3D 03 01 07 OID prime256v1
654 // 03 42 00 BIT STRING (66 bytes, 0 unused bits)
655 // 04 XX...XX SEC1 uncompressed point (65 bytes)
656 // starting with 0x04
657 //
658 // The SEC1 point is the last 65 bytes. We validate the marker byte
659 // and let `ring::UnparsedPublicKey::new` reject malformed bytes
660 // later if the prefix is corrupt.
661 if der.len() < 65 {
662 return Err(AttestationError::CertificationDeclarationMalformed);
663 }
664 let point = &der[der.len() - 65..];
665 if point[0] != 0x04 {
666 return Err(AttestationError::CertificationDeclarationMalformed);
667 }
668 Ok(point.to_vec())
669}
670
671/// Verify an ECDSA-P256 / SHA-256 signature against a SEC1-uncompressed
672/// P-256 public key.
673///
674/// CMS `SignerInfo.signature` carries ECDSA signatures as a DER
675/// `ECDSA-Sig-Value` (SEQUENCE of r, s) — confirmed against a real
676/// CSA-signed CD (Tapo P110M, M6.6.5 validation), and matching chip's
677/// `CMS_Sign`. A raw fixed-form (r||s, exactly 64 bytes) signature is
678/// also accepted for compatibility with the historical local test
679/// fixtures generated by `xtask capture-cd`.
680///
681/// Maps any verification failure to
682/// [`AttestationError::CertificationDeclarationSignatureInvalid`] —
683/// the caller (the per-root loop in
684/// [`verify_certification_declaration`]) only cares whether *some*
685/// trusted root accepted the signature, not which one rejected it.
686fn verify_ecdsa_p256_sha256(
687 public_key: &[u8],
688 msg: &[u8],
689 sig: &[u8],
690) -> Result<(), AttestationError> {
691 use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1, ECDSA_P256_SHA256_FIXED};
692 let asn1 = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, public_key);
693 if asn1.verify(msg, sig).is_ok() {
694 return Ok(());
695 }
696 if sig.len() == 64 {
697 let fixed = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, public_key);
698 if fixed.verify(msg, sig).is_ok() {
699 return Ok(());
700 }
701 }
702 Err(AttestationError::CertificationDeclarationSignatureInvalid)
703}
704
705#[cfg(test)]
706mod tests {
707 // The CD test helpers pair `dac_origin_vid`/`dac_origin_pid` and
708 // `origin_vid`/`origin_pid` (and VendorId/ProductId locals) by design —
709 // the near-identical names mirror the spec field pairs. Same carve-out
710 // as `tests/support/mod.rs`.
711 #![allow(clippy::similar_names)]
712
713 use super::*;
714
715 #[test]
716 #[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
717 fn verify_accepts_der_encoded_ecdsa_signature() {
718 // CMS `SignerInfo.signature` carries ECDSA signatures as a DER
719 // `ECDSA-Sig-Value` (SEQUENCE of r, s) — confirmed on a real
720 // CSA-signed CD (Tapo P110M, M6.6.5 validation: 70-byte `0x30 44 …`).
721 // chip's `CMS_Sign` emits the same (`ConvertECDSASignatureRawToDER`).
722 use ring::rand::SystemRandom;
723 use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING};
724
725 let rng = SystemRandom::new();
726 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
727 let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng)
728 .unwrap();
729 let msg = b"certification declaration content";
730 let sig = kp.sign(&rng, msg).unwrap(); // DER-encoded ECDSA-Sig-Value
731 let pk = kp.public_key().as_ref();
732
733 verify_ecdsa_p256_sha256(pk, msg, sig.as_ref())
734 .expect("DER-encoded CMS signature must verify");
735 }
736
737 #[test]
738 fn with_example_device_roots_loads_bundled_roots() {
739 // ATT-3: three roots — synthetic (loopback), chip test authority,
740 // CSA production key 001 (verifies the ESP32-C6). All three DERs
741 // parse, so none are silently skipped.
742 let trust = CdSigningRoots::with_example_device_roots();
743 assert_eq!(trust.len(), 3);
744 assert!(!trust.is_empty());
745 }
746
747 #[test]
748 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
749 fn parse_pem_public_key_extracts_65_byte_sec1_point() {
750 const PEM: &[u8] = include_bytes!("./csa_cd_signing_roots/csa-test-cd-signing-root.pem");
751 let key = parse_pem_public_key(PEM).expect("happy path parses");
752 assert_eq!(key.len(), 65, "SEC1 uncompressed P-256 point");
753 assert_eq!(key[0], 0x04, "uncompressed-point marker byte");
754 }
755
756 #[test]
757 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
758 fn parse_pem_public_key_rejects_garbage() {
759 let err = parse_pem_public_key(b"not a PEM").expect_err("garbage rejected");
760 assert!(matches!(
761 err,
762 AttestationError::CertificationDeclarationMalformed
763 ));
764 }
765
766 #[test]
767 #[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
768 fn from_pem_empty_input_yields_empty_trust_store() {
769 let trust = CdSigningRoots::from_pem(&[]).unwrap();
770 assert!(trust.is_empty());
771 assert_eq!(trust.len(), 0);
772 }
773
774 #[test]
775 #[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
776 fn from_cert_der_extracts_p256_pubkey_from_x509_cert() {
777 // Real-world CD signing roots (CSA DCL / connectedhomeip
778 // `credentials/production/cd-certs/`) are X.509 certificates, not bare
779 // SubjectPublicKeyInfo PEMs. `from_cert_der` must extract the cert's
780 // P-256 subject public key. We synthesise a self-signed P-256 cert with
781 // a known key and assert the extracted SEC1 point matches it byte-for-byte.
782 use matter_cert::test_support::{build_x509_der, TestCertFields};
783 use matter_cert::{
784 DistinguishedName, DnAttribute, Extensions, MatterTime, PublicKey, Signature,
785 };
786 use ring::rand::SystemRandom;
787 use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING};
788
789 let rng = SystemRandom::new();
790 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
791 let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng)
792 .unwrap();
793 let expected = kp.public_key().as_ref().to_vec(); // 65-byte SEC1 uncompressed
794 let pk = PublicKey::from_slice(&expected).unwrap();
795
796 let dn = DistinguishedName::new(vec![DnAttribute::CommonName(
797 "Test CD Signing Key (synthetic)".into(),
798 )]);
799 let der = build_x509_der(
800 TestCertFields {
801 serial: vec![0x01],
802 issuer: dn.clone(),
803 not_before: MatterTime::from_unix_secs(1_700_000_000),
804 not_after: MatterTime::NO_EXPIRY,
805 subject: dn,
806 public_key: pk,
807 extensions: Extensions::default(),
808 signature: Signature::new([0u8; 64]),
809 },
810 pkcs8.as_ref(), // self-signed
811 )
812 .expect("synthetic CD signing cert builds");
813
814 let trust = CdSigningRoots::from_cert_der(&[&der]).expect("cert parses");
815 assert_eq!(trust.len(), 1);
816 assert_eq!(
817 trust.public_keys[0], expected,
818 "extracted SEC1 public key must match the cert's subject key"
819 );
820 }
821
822 #[test]
823 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
824 fn from_cert_der_rejects_non_certificate_bytes() {
825 let err = CdSigningRoots::from_cert_der(&[b"not a certificate"])
826 .expect_err("garbage DER rejected");
827 assert!(matches!(
828 err,
829 AttestationError::CertificationDeclarationMalformed
830 ));
831 }
832
833 #[test]
834 fn parse_inner_cd_tlv_extracts_vendor_id_and_pid_list() {
835 // Test-code carve-out: see CLAUDE.md.
836 #![allow(clippy::unwrap_used, clippy::expect_used)]
837 use matter_codec::{Tag, TlvWriter};
838 let mut buf = Vec::new();
839 let mut w = TlvWriter::new(&mut buf);
840 w.start_structure(Tag::Anonymous).unwrap();
841 w.put_uint(Tag::Context(0), 1).unwrap(); // format_version
842 w.put_uint(Tag::Context(1), 0xFFF1).unwrap(); // vendor_id
843 w.start_array(Tag::Context(2)).unwrap();
844 w.put_uint(Tag::Anonymous, 0x8001).unwrap();
845 w.put_uint(Tag::Anonymous, 0x8002).unwrap();
846 w.end_container().unwrap(); // close array
847 w.end_container().unwrap(); // close struct
848
849 let parsed = parse_inner_cd_tlv(&buf).expect("happy path decodes");
850 assert_eq!(parsed.vendor_id, VendorId::new(0xFFF1));
851 assert_eq!(
852 parsed.product_ids,
853 vec![ProductId::new(0x8001), ProductId::new(0x8002)]
854 );
855 assert!(
856 parsed.authorized_paa_list.is_none(),
857 "no tag 11 → no PAA constraint"
858 );
859 }
860
861 /// Build a minimal valid CD inner TLV, optionally carrying an
862 /// `authorized_paa_list` (tag 11) of the given SKID entries.
863 #[cfg(test)]
864 fn cd_tlv_with_paa_list(entries: Option<&[&[u8]]>) -> Vec<u8> {
865 #![allow(clippy::unwrap_used)]
866 use matter_codec::{Tag, TlvWriter};
867 let mut buf = Vec::new();
868 let mut w = TlvWriter::new(&mut buf);
869 w.start_structure(Tag::Anonymous).unwrap();
870 w.put_uint(Tag::Context(1), 0xFFF1).unwrap();
871 w.start_array(Tag::Context(2)).unwrap();
872 w.put_uint(Tag::Anonymous, 0x8000).unwrap();
873 w.end_container().unwrap();
874 if let Some(entries) = entries {
875 w.start_array(Tag::Context(11)).unwrap();
876 for e in entries {
877 w.put_bytes(Tag::Anonymous, e).unwrap();
878 }
879 w.end_container().unwrap();
880 }
881 w.end_container().unwrap();
882 buf
883 }
884
885 #[test]
886 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
887 fn parse_inner_cd_tlv_extracts_authorized_paa_list() {
888 let a = [0xAAu8; 20];
889 let b = [0xBBu8; 20];
890 let buf = cd_tlv_with_paa_list(Some(&[&a, &b]));
891 let parsed = parse_inner_cd_tlv(&buf).expect("tag 11 decodes");
892 assert_eq!(parsed.authorized_paa_list, Some(vec![a, b]));
893 }
894
895 #[test]
896 fn parse_inner_cd_tlv_rejects_wrong_length_paa_entry() {
897 // chip requires each authorized_paa_list entry to be exactly 20
898 // bytes; a 19-byte entry is a structural error.
899 let short = [0xAAu8; 19];
900 let buf = cd_tlv_with_paa_list(Some(&[&short]));
901 assert!(matches!(
902 parse_inner_cd_tlv(&buf),
903 Err(AttestationError::CertificationDeclarationTlvMalformed)
904 ));
905 }
906
907 #[test]
908 fn paa_is_authorized_matrix() {
909 let a = [0xAAu8; 20];
910 let b = [0xBBu8; 20];
911 // No list → always authorized (CD imposes no constraint).
912 assert!(paa_is_authorized(None, Some(&a)));
913 assert!(paa_is_authorized(None, None));
914 // List present, SKID in it → authorized.
915 assert!(paa_is_authorized(Some(&[a, b]), Some(&a)));
916 // List present, SKID not in it → rejected.
917 let c = [0xCCu8; 20];
918 assert!(!paa_is_authorized(Some(&[a, b]), Some(&c)));
919 // List present, no device SKID → rejected.
920 assert!(!paa_is_authorized(Some(&[a, b]), None));
921 // Empty list → nothing authorized.
922 assert!(!paa_is_authorized(Some(&[]), Some(&a)));
923 }
924
925 #[test]
926 fn parse_inner_cd_tlv_ignores_forward_compat_fields() {
927 // Test-code carve-out: see CLAUDE.md.
928 #![allow(clippy::unwrap_used, clippy::expect_used)]
929 use matter_codec::{Tag, TlvWriter};
930 // Hand-roll a CD with tag 0 (format_version), tag 1 (vid), tag 2 (pids),
931 // tag 3 (device_type_id), tag 4 (certificate_id utf8), tag 5..8 (security_*,
932 // version_number, certification_type), AND a fake tag 99 (future field).
933 let mut buf = Vec::new();
934 let mut w = TlvWriter::new(&mut buf);
935 w.start_structure(Tag::Anonymous).unwrap();
936 w.put_uint(Tag::Context(0), 1).unwrap();
937 w.put_uint(Tag::Context(1), 0xFFF1).unwrap();
938 w.start_array(Tag::Context(2)).unwrap();
939 w.put_uint(Tag::Anonymous, 0x8001).unwrap();
940 w.end_container().unwrap();
941 w.put_uint(Tag::Context(3), 0x0100).unwrap();
942 w.put_utf8(Tag::Context(4), "CSA-ID").unwrap();
943 w.put_uint(Tag::Context(5), 0).unwrap();
944 w.put_uint(Tag::Context(6), 0).unwrap();
945 w.put_uint(Tag::Context(7), 1).unwrap();
946 w.put_uint(Tag::Context(8), 0).unwrap();
947 w.put_uint(Tag::Context(99), 0xDEAD).unwrap(); // unknown future field
948 w.end_container().unwrap();
949
950 let parsed = parse_inner_cd_tlv(&buf).expect("forward-compat decode");
951 assert_eq!(parsed.vendor_id, VendorId::new(0xFFF1));
952 assert_eq!(parsed.product_ids, vec![ProductId::new(0x8001)]);
953 }
954
955 #[test]
956 fn parse_inner_cd_tlv_rejects_missing_vid() {
957 // Test-code carve-out: see CLAUDE.md.
958 #![allow(clippy::unwrap_used, clippy::expect_used)]
959 use matter_codec::{Tag, TlvWriter};
960 let mut buf = Vec::new();
961 let mut w = TlvWriter::new(&mut buf);
962 w.start_structure(Tag::Anonymous).unwrap();
963 // No tag 1 (vendor_id).
964 w.start_array(Tag::Context(2)).unwrap();
965 w.put_uint(Tag::Anonymous, 0x8001).unwrap();
966 w.end_container().unwrap();
967 w.end_container().unwrap();
968
969 let err = parse_inner_cd_tlv(&buf).expect_err("missing vid rejected");
970 assert!(matches!(
971 err,
972 AttestationError::CertificationDeclarationTlvMalformed
973 ));
974 }
975
976 #[test]
977 fn parse_inner_cd_tlv_rejects_garbage() {
978 // Test-code carve-out: see CLAUDE.md.
979 #![allow(clippy::unwrap_used, clippy::expect_used)]
980 let err = parse_inner_cd_tlv(&[0xFF]).expect_err("garbage rejected");
981 assert!(matches!(
982 err,
983 AttestationError::CertificationDeclarationTlvMalformed
984 ));
985 }
986
987 #[test]
988 fn parse_inner_cd_tlv_captures_dac_origin_fields() {
989 // Test-code carve-out: see CLAUDE.md.
990 #![allow(clippy::unwrap_used, clippy::expect_used)]
991 let tlv = build_inner_cd_tlv(0xFFF1, 0x8001, Some(0x1234), Some(0x5678), &[0x8001]);
992 let parsed = parse_inner_cd_tlv(&tlv).expect("decodes with dac_origin");
993 assert_eq!(parsed.vendor_id, VendorId::new(0xFFF1));
994 assert_eq!(parsed.product_ids, vec![ProductId::new(0x8001)]);
995 assert_eq!(parsed.dac_origin_vendor_id, Some(VendorId::new(0x1234)));
996 assert_eq!(parsed.dac_origin_product_id, Some(ProductId::new(0x5678)));
997 }
998
999 // ── Fix B — CD dac_origin override binding (Matter §6.2.3) ──────────────
1000 //
1001 // These tests sign a synthetic CD with the bundled CSA-test signing key
1002 // (which `CdSigningRoots::with_example_device_roots()` trusts) and run it
1003 // through the public `verify_certification_declaration`, exercising the
1004 // dac_origin override path end-to-end:
1005 //
1006 // - CD with dac_origin set + DAC matching the ORIGIN VID/PID (but NOT
1007 // the CD's own vendor_id) → accepted.
1008 // - CD with dac_origin set + DAC matching neither → rejected.
1009 // - CD WITHOUT dac_origin → unchanged: compares vendor_id /
1010 // product_id_array.
1011
1012 /// PKCS#8 private key for the bundled CSA-test CD signing root. The
1013 /// matching public key is bundled in
1014 /// `csa_cd_signing_roots/csa-test-cd-signing-root.pem` and trusted by
1015 /// `CdSigningRoots::with_example_device_roots()`.
1016 const CSA_TEST_CD_SIGNING_KEY_PKCS8: &[u8] = include_bytes!(
1017 "../../../../../test-vectors/commissioning/cd/csa-test-cd-signing-root.pkcs8.der"
1018 );
1019
1020 /// Build the inner CD TLV per Matter Core Spec §6.3.1, optionally
1021 /// including the `dac_origin_*` override fields (tags 9 / 10).
1022 ///
1023 /// Mirrors `xtask/src/capture_cd.rs::build_inner_cd_tlv` but adds the
1024 /// optional tags 9/10 so the Fix B override path can be exercised.
1025 #[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
1026 fn build_inner_cd_tlv(
1027 vendor_id: u16,
1028 product_id: u16,
1029 dac_origin_vid: Option<u16>,
1030 dac_origin_pid: Option<u16>,
1031 product_id_array: &[u16],
1032 ) -> Vec<u8> {
1033 use matter_codec::{Tag, TlvWriter};
1034 let mut buf = Vec::new();
1035 {
1036 let mut w = TlvWriter::new(&mut buf);
1037 w.start_structure(Tag::Anonymous).unwrap();
1038 w.put_uint(Tag::Context(0), 1).unwrap(); // format_version
1039 w.put_uint(Tag::Context(1), u64::from(vendor_id)).unwrap(); // vendor_id
1040 w.start_array(Tag::Context(2)).unwrap(); // product_id_array
1041 let pids: Vec<u16> = if product_id_array.is_empty() {
1042 vec![product_id]
1043 } else {
1044 product_id_array.to_vec()
1045 };
1046 for p in pids {
1047 w.put_uint(Tag::Anonymous, u64::from(p)).unwrap();
1048 }
1049 w.end_container().unwrap();
1050 w.put_uint(Tag::Context(3), 0x0100).unwrap(); // device_type_id
1051 w.put_utf8(Tag::Context(4), "CSA00000000000000").unwrap(); // certificate_id
1052 w.put_uint(Tag::Context(5), 0).unwrap(); // security_level
1053 w.put_uint(Tag::Context(6), 0).unwrap(); // security_information
1054 w.put_uint(Tag::Context(7), 1).unwrap(); // version_number
1055 w.put_uint(Tag::Context(8), 0).unwrap(); // certification_type
1056 if let Some(v) = dac_origin_vid {
1057 w.put_uint(Tag::Context(9), u64::from(v)).unwrap();
1058 }
1059 if let Some(p) = dac_origin_pid {
1060 w.put_uint(Tag::Context(10), u64::from(p)).unwrap();
1061 }
1062 w.end_container().unwrap();
1063 }
1064 buf
1065 }
1066
1067 /// Sign `content` (the inner CD TLV) into a CMS `SignedData`
1068 /// `ContentInfo` DER blob with the bundled CSA-test key, using the
1069 /// no-`signedAttrs` shape the verifier expects. Mirrors
1070 /// `xtask/src/capture_cd.rs::sign_into_cms`.
1071 #[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
1072 fn sign_into_cms(content: &[u8]) -> Vec<u8> {
1073 use cms::cert::IssuerAndSerialNumber;
1074 use cms::content_info::{CmsVersion, ContentInfo};
1075 use cms::signed_data::{
1076 EncapsulatedContentInfo, SignedData, SignerIdentifier, SignerInfo, SignerInfos,
1077 };
1078 use const_oid::ObjectIdentifier;
1079 use der::asn1::{Any, AnyRef, OctetString, SetOfVec};
1080 use der::{Encode, Tag as DerTag};
1081 use ring::rand::SystemRandom;
1082 use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
1083 use spki::AlgorithmIdentifierOwned;
1084 use x509_cert::name::RdnSequence;
1085 use x509_cert::serial_number::SerialNumber;
1086
1087 const ID_DATA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.1");
1088 const ID_SIGNED_DATA: ObjectIdentifier =
1089 ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.2");
1090 const ID_SHA_256: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
1091 const ECDSA_WITH_SHA_256: ObjectIdentifier =
1092 ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2");
1093
1094 let rng = SystemRandom::new();
1095 let key = EcdsaKeyPair::from_pkcs8(
1096 &ECDSA_P256_SHA256_FIXED_SIGNING,
1097 CSA_TEST_CD_SIGNING_KEY_PKCS8,
1098 &rng,
1099 )
1100 .expect("bundled CSA-test CD signing key loads");
1101 let signature = key.sign(&rng, content).expect("sign eContent");
1102
1103 let econtent_any =
1104 Any::new(DerTag::OctetString, content.to_vec()).expect("Any(OctetString)");
1105 let encap = EncapsulatedContentInfo {
1106 econtent_type: ID_DATA,
1107 econtent: Some(econtent_any),
1108 };
1109 let sha256 = AlgorithmIdentifierOwned {
1110 oid: ID_SHA_256,
1111 parameters: None,
1112 };
1113 let digest_algorithms =
1114 SetOfVec::try_from(vec![sha256.clone()]).expect("digest_algorithms");
1115 let serial = SerialNumber::new(&[0x01]).expect("serial");
1116 let sid = SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber {
1117 issuer: RdnSequence::default(),
1118 serial_number: serial,
1119 });
1120 let signature_octets =
1121 OctetString::new(signature.as_ref().to_vec()).expect("signature octets");
1122 let signer_info = SignerInfo {
1123 version: CmsVersion::V1,
1124 sid,
1125 digest_alg: sha256,
1126 signed_attrs: None,
1127 signature_algorithm: AlgorithmIdentifierOwned {
1128 oid: ECDSA_WITH_SHA_256,
1129 parameters: None,
1130 },
1131 signature: signature_octets,
1132 unsigned_attrs: None,
1133 };
1134 let signer_infos = SignerInfos(SetOfVec::try_from(vec![signer_info]).expect("signer set"));
1135 let signed_data = SignedData {
1136 version: CmsVersion::V1,
1137 digest_algorithms,
1138 encap_content_info: encap,
1139 certificates: None,
1140 crls: None,
1141 signer_infos,
1142 };
1143 let signed_data_der = signed_data.to_der().expect("SignedData der");
1144 let signed_data_any =
1145 Any::from(AnyRef::try_from(signed_data_der.as_slice()).expect("AnyRef"));
1146 let content_info = ContentInfo {
1147 content_type: ID_SIGNED_DATA,
1148 content: signed_data_any,
1149 };
1150 content_info.to_der().expect("ContentInfo der")
1151 }
1152
1153 #[test]
1154 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
1155 fn dac_origin_present_dac_matches_origin_is_accepted() {
1156 // CD's own vendor_id/product_id_array are 0xFFF1 / 0x8001, but
1157 // dac_origin says the DAC is scoped to 0x1234 / 0x5678. A DAC at
1158 // 0x1234 / 0x5678 must be accepted (bound to the origin fields),
1159 // even though it does NOT match the CD's own vendor_id.
1160 let tlv = build_inner_cd_tlv(0xFFF1, 0x8001, Some(0x1234), Some(0x5678), &[0x8001]);
1161 let cd = sign_into_cms(&tlv);
1162 let trust = CdSigningRoots::with_example_device_roots();
1163
1164 verify_certification_declaration(
1165 &cd,
1166 VendorId::new(0x1234),
1167 ProductId::new(0x5678),
1168 &trust,
1169 )
1170 .expect("DAC matching dac_origin VID/PID must be accepted");
1171 }
1172
1173 #[test]
1174 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
1175 fn dac_origin_present_dac_matches_neither_is_rejected() {
1176 // dac_origin = 0x1234/0x5678. A DAC at 0xFFF1/0x8001 (the CD's own
1177 // vendor_id/pid) must be REJECTED, because the override path binds
1178 // against dac_origin, not the CD's own fields.
1179 let tlv = build_inner_cd_tlv(0xFFF1, 0x8001, Some(0x1234), Some(0x5678), &[0x8001]);
1180 let cd = sign_into_cms(&tlv);
1181 let trust = CdSigningRoots::with_example_device_roots();
1182
1183 let err = verify_certification_declaration(
1184 &cd,
1185 VendorId::new(0xFFF1),
1186 ProductId::new(0x8001),
1187 &trust,
1188 )
1189 .expect_err("DAC not matching dac_origin must be rejected");
1190 assert!(
1191 matches!(
1192 err,
1193 AttestationError::CertificationDeclarationVidMismatch {
1194 declared,
1195 expected,
1196 } if declared == VendorId::new(0x1234) && expected == VendorId::new(0xFFF1)
1197 ),
1198 "expected VID mismatch against the dac_origin VID, got {err:?}"
1199 );
1200 }
1201
1202 #[test]
1203 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
1204 fn no_dac_origin_uses_vendor_id_and_pid_array() {
1205 // Without dac_origin, the standard path compares against the CD's
1206 // own vendor_id / product_id_array (unchanged behaviour).
1207 let tlv = build_inner_cd_tlv(0xFFF1, 0x8001, None, None, &[0x8001, 0x8002]);
1208 let cd = sign_into_cms(&tlv);
1209 let trust = CdSigningRoots::with_example_device_roots();
1210
1211 // A PID present in the array is accepted.
1212 verify_certification_declaration(
1213 &cd,
1214 VendorId::new(0xFFF1),
1215 ProductId::new(0x8002),
1216 &trust,
1217 )
1218 .expect("DAC matching vendor_id and a member of product_id_array accepted");
1219
1220 // A PID NOT in the array is rejected (still the standard path).
1221 let err = verify_certification_declaration(
1222 &cd,
1223 VendorId::new(0xFFF1),
1224 ProductId::new(0x9999),
1225 &trust,
1226 )
1227 .expect_err("PID outside product_id_array rejected");
1228 assert!(
1229 matches!(err, AttestationError::CertificationDeclarationPidMismatch(p)
1230 if p == ProductId::new(0x9999)),
1231 "expected PID mismatch, got {err:?}"
1232 );
1233 }
1234}