matter_commissioning/attestation/chain.rs
1//! Device attestation chain validation.
2//!
3//! [`verify_chain`] runs the load-bearing X.509 path validation
4//! through `rustls-webpki` 0.103 and layers Matter-specific overlay
5//! checks (VID/PID equality per Matter §6.2.3) on top.
6//!
7//! Pure sans-I/O — no network, no clock reads, no internal state.
8//! Callers supply [`matter_cert::time::MatterTime`] explicitly so
9//! tests pin behaviour to fixture validity windows. The DAC public
10//! key surfaced in [`ChainVerification`] is the same bytes
11//! [`crate::attestation::Dac`]'s `public_key()` accessor returns;
12//! M6.2.3 will feed it into `verify_attestation_response`.
13
14#![forbid(unsafe_code)]
15
16use core::time::Duration;
17
18use matter_cert::time::MatterTime;
19use rustls_pki_types::{CertificateDer, SignatureVerificationAlgorithm, TrustAnchor, UnixTime};
20use webpki::{EndEntityCert, KeyUsage};
21
22use crate::attestation::error::{map_webpki_error, AttestationError};
23use crate::attestation::extensions::{ProductId, VendorId};
24use crate::attestation::trust_store::PaaTrustStore;
25use crate::attestation::x509::{Dac, Paa, Pai};
26
27/// Signature algorithms accepted in Matter attestation chains.
28///
29/// Matter Core Spec §6.2 mandates ECDSA over the NIST P-256 curve
30/// with SHA-256 for every signature in the DAC -> PAI -> PAA chain.
31/// We list exactly that algorithm and no others: any cert signed
32/// with a different scheme (e.g. RSA, `EdDSA`, P-384) is rejected by
33/// webpki with `UnsupportedSignatureAlgorithm`, which our
34/// [`map_webpki_error`] funnels into
35/// [`AttestationError::InvalidChain`].
36static MATTER_SIG_ALGS: &[&dyn SignatureVerificationAlgorithm] = &[webpki::ring::ECDSA_P256_SHA256];
37
38/// Build a [`TrustAnchor`] from one of our [`Paa`]s.
39///
40/// webpki's anchor wants pre-parsed `Subject`, `SubjectPublicKeyInfo`,
41/// and (optionally) `NameConstraints` byte slices. Rather than re-parse
42/// the DER ourselves — and risk drifting from webpki's own notion of
43/// each field's byte range — we hand the original DER to webpki's
44/// dedicated anchor-extraction entry point and let it carve up the
45/// slices.
46///
47/// # Why this returns `TrustAnchor<'static>` rather than `TrustAnchor<'_>`
48///
49/// webpki 0.103's [`webpki::anchor_from_trusted_cert`] is signed as
50/// `fn(&'a CertificateDer<'a>) -> Result<TrustAnchor<'a>, _>` — the
51/// returned anchor borrows from the `CertificateDer` wrapper, not the
52/// underlying `&[u8]`. If we construct the `CertificateDer` locally
53/// (which we must — `Paa` stores `Vec<u8>`, not `CertificateDer`),
54/// the returned anchor would borrow from a stack local and the
55/// function couldn't return it. So we [`TrustAnchor::to_owned`] the
56/// result, copying the three small slices (subject DN, SPKI, optional
57/// name constraints — together a few hundred bytes) onto the heap.
58/// T6's `verify_chain` calls this once per `verify_chain` invocation,
59/// so the cost is negligible (the path validator itself does far more
60/// allocation per call).
61///
62/// # Errors
63///
64/// Returns [`AttestationError::Parse`] if webpki cannot parse the PAA
65/// DER. Should be unreachable in practice — [`Paa::from_der`] already
66/// validated the bytes as a self-signed Matter PAA in M6.2.1 — but
67/// `x509-parser` (M6.2.1's parser) and webpki's internal parser are
68/// distinct implementations, so we wrap rather than panic on any
69/// divergence.
70///
71/// # Why webpki 0.103 doesn't expose `webpki::types::*`
72///
73/// Pre-0.103, webpki re-exported `rustls-pki-types` items under
74/// `webpki::types::*`. 0.103 dropped the re-export — the types now
75/// live at their canonical path (`rustls_pki_types::*`), and crates
76/// like ours that name them in signatures pull `rustls-pki-types`
77/// directly. The Cargo.toml comment on that dep records this.
78//
79// pub(crate) — the only legitimate caller is `verify_chain` (T6).
80// External callers don't need `TrustAnchor` in their hands; they
81// see only [`AttestationError`] / [`ChainVerification`].
82pub(crate) fn paa_to_trust_anchor(paa: &Paa) -> Result<TrustAnchor<'static>, AttestationError> {
83 // `CertificateDer::from(&[u8])` is a zero-cost newtype wrap — no
84 // copy of the PAA DER.
85 let cert_der = CertificateDer::from(paa.der());
86 webpki::anchor_from_trusted_cert(&cert_der)
87 .map(|anchor| anchor.to_owned())
88 .map_err(|e| AttestationError::Parse(Box::new(e)))
89}
90
91/// Outcome of a successful [`verify_chain`] call.
92///
93/// Returned by value (cheap — a few small fields plus an owned DER
94/// public-key blob). Callers persist the [`VendorId`]/[`ProductId`]
95/// for fabric records and pass `dac_public_key` to M6.2.3's
96/// `verify_attestation_response`.
97#[derive(Debug, Clone)]
98pub struct ChainVerification {
99 /// [`VendorId`] matched on both the DAC and PAI subject DNs.
100 pub vendor_id: VendorId,
101 /// [`ProductId`] matched on the DAC subject DN (and on the PAI if
102 /// the PAI was product-scoped).
103 pub product_id: ProductId,
104 /// DAC subject public key — raw P-256 SEC1 uncompressed bytes
105 /// (`0x04 || X || Y`, 65 bytes).
106 pub dac_public_key: Vec<u8>,
107 /// DER-encoded PAA subject Name. Opaque to most callers; kept for
108 /// audit logging ("attested by PAA `<subject>`").
109 pub paa_subject: Vec<u8>,
110 /// `SubjectKeyIdentifier` of the PAA that anchored the chain, if it
111 /// carries one. Used to enforce a Certification Declaration's
112 /// `authorized_paa_list` (Matter §6.2.3): the CD may restrict which
113 /// PAAs are allowed to attest the device, matched on this SKID.
114 pub paa_skid: Option<Vec<u8>>,
115}
116
117/// Verify a Matter attestation chain.
118///
119/// Runs `rustls-webpki`'s RFC 5280 path validation (signature, name
120/// chaining, validity windows, and `BasicConstraints` cA/path-length),
121/// then layers Matter §6.2.3's VID/PID equality overlay on top. The DAC
122/// is treated as the end-entity, the PAI as the sole intermediate, and
123/// the trust store as the set of candidate PAAs.
124///
125/// **What webpki does *not* check.** `rustls-webpki` deliberately
126/// **ignores the `KeyUsage` extension** for validation (see its
127/// `verify_cert.rs`: *"For cert validation, we ignore the `KeyUsage`
128/// extension"*), and it treats `ExtendedKeyUsage` as
129/// *required-if-present* only — an absent EKU passes. We pass
130/// [`KeyUsage::client_auth`] below, so a present EKU that lacks
131/// `id-kp-clientAuth` is still rejected, but an EKU-less cert is not.
132/// The full Matter attestation-certificate *profile* — the `KeyUsage`
133/// bits, `SubjectKeyIdentifier`/`AuthorityKeyIdentifier` presence, the
134/// certificate version, the signature algorithm, and the role-correct
135/// `BasicConstraints` — is enforced separately by the crate-internal
136/// `verify_attestation_cert_format`, which the commissioner runs
137/// alongside this function (as chip's device attestation verifier does).
138///
139/// Pure sans-I/O: no clock reads, no network, no internal state.
140/// Time is supplied via [`MatterTime`] so tests can pin behaviour to
141/// fixture validity windows.
142///
143/// # Errors
144///
145/// - [`AttestationError::TimeBoundsViolation`] — a cert in the chain
146/// was outside its validity window at `at`.
147/// - [`AttestationError::BasicConstraintsViolation`] — a non-CA cert
148/// was flagged as a CA, or the path-length constraint was violated.
149/// - [`AttestationError::UntrustedRoot`] — no PAA in `trust_store`
150/// anchors the PAI.
151/// - [`AttestationError::InvalidChain`] — any other webpki rejection
152/// (signature mismatch, unsupported algorithm, missing EKU, …).
153/// - [`AttestationError::VidMismatch`] — DAC subject VID does not
154/// equal PAI subject VID.
155/// - [`AttestationError::PaiVidNotAuthorized`] — PAI is product-scoped
156/// (carries a subject PID) and that PID does not equal the DAC's.
157/// - [`AttestationError::PaaVidScopeMismatch`] — the anchoring PAA is
158/// VID-scoped and its scoped VID does not equal the DAC/PAI subject
159/// VID (Matter §6.2.2.1).
160/// - [`AttestationError::Parse`] — a PAA in the trust store could not
161/// be re-parsed by webpki (should be unreachable, since
162/// [`Paa::from_der`] already validated the bytes).
163pub fn verify_chain(
164 dac: &Dac,
165 pai: &Pai,
166 trust_store: &PaaTrustStore,
167 at: MatterTime,
168) -> Result<ChainVerification, AttestationError> {
169 // 1. Lift every PAA in the trust store into a webpki TrustAnchor.
170 // Each anchor borrows its bytes from a heap copy we own
171 // (paa_to_trust_anchor's `to_owned()` call), so the resulting
172 // Vec is `'static`-borrowed and can outlive any stack-local
173 // CertificateDer wrappers below.
174 let anchors: Vec<TrustAnchor<'static>> = trust_store
175 .iter()
176 .map(paa_to_trust_anchor)
177 .collect::<Result<Vec<_>, _>>()?;
178
179 // 2. Wrap DAC + PAI DER as the webpki types. `CertificateDer::from`
180 // on a `&[u8]` is a zero-cost newtype wrap.
181 let dac_der = CertificateDer::from(dac.der());
182 let pai_der = CertificateDer::from(pai.der());
183 let intermediates = [pai_der];
184 let end_entity = EndEntityCert::try_from(&dac_der).map_err(map_webpki_error)?;
185
186 // 3. Project MatterTime onto webpki's UnixTime. MatterTime stores
187 // seconds-since-Matter-epoch (2000-01-01); its `to_unix_secs`
188 // converts to seconds-since-Unix-epoch, which is the unit
189 // UnixTime takes.
190 let now = UnixTime::since_unix_epoch(Duration::from_secs(at.to_unix_secs()));
191
192 // 4. Path validation. webpki checks: signature on each cert with
193 // `MATTER_SIG_ALGS`; validity window vs `now`; BasicConstraints
194 // on every CA; KeyUsage matches the requested usage; EKU
195 // contains `id-kp-clientAuth` (Matter §6.5). No revocation
196 // (Matter doesn't define CRLs/OCSP for attestation in M6.2);
197 // no extra `verify_path` predicate (the Matter overlay below
198 // runs after webpki returns so we can produce typed errors
199 // rather than `Error::Other`).
200 end_entity
201 .verify_for_usage(
202 MATTER_SIG_ALGS,
203 &anchors,
204 &intermediates,
205 now,
206 KeyUsage::client_auth(),
207 None,
208 None,
209 )
210 .map_err(map_webpki_error)?;
211
212 // 5. Matter §6.2.3 overlay — VID/PID equality. webpki has already
213 // accepted the signatures and name-chain, so a mismatch here
214 // is a Matter-policy rejection rather than an X.509 one.
215 let dac_vid = dac.subject_vid();
216 let pai_vid = pai.subject_vid();
217 if dac_vid != pai_vid {
218 return Err(AttestationError::VidMismatch {
219 dac: dac_vid,
220 pai: pai_vid,
221 });
222 }
223 if let Some(pai_pid) = pai.subject_pid() {
224 if pai_pid != dac.subject_pid() {
225 return Err(AttestationError::PaiVidNotAuthorized);
226 }
227 }
228
229 // 6. Identify which PAA in the store actually anchored the chain
230 // so callers can audit-log "attested by PAA <subject>". Walk
231 // the trust store and find the PAA whose subject Name matches
232 // the PAI's issuer Name. Self-signed PAAs have
233 // issuer == subject (RFC 5280 §4.1.2.4), so this is the PAA
234 // webpki must have selected. If webpki accepted the chain
235 // above, exactly one such PAA exists; the `ok_or` is a safety
236 // net against subject-name encoding drift between webpki and
237 // x509-parser and should be unreachable.
238 // x509-parser's `X509Name::as_raw()` returns the full DER-encoded
239 // `Name` SEQUENCE (tag + length + contents), but webpki's
240 // `TrustAnchor::subject` field stores only the SEQUENCE contents
241 // (it strips the outer tag/length when extracting from the cert
242 // — see `extract_trust_anchor_from_v1_cert_der` in webpki's
243 // `trust_anchor.rs`). So we strip the SEQUENCE wrapper from the
244 // PAI's issuer to put both sides on the same footing before
245 // byte-comparing.
246 let pai_issuer_contents =
247 strip_sequence_wrapper(pai.issuer_raw()).ok_or(AttestationError::UntrustedRoot)?;
248 let (anchoring_paa, paa_subject) = trust_store
249 .iter()
250 .find_map(|paa| {
251 let anchor = paa_to_trust_anchor(paa).ok()?;
252 if anchor.subject.as_ref() == pai_issuer_contents {
253 Some((paa, anchor.subject.as_ref().to_vec()))
254 } else {
255 None
256 }
257 })
258 .ok_or(AttestationError::UntrustedRoot)?;
259
260 // 7. Matter §6.2.2.1 — VID-scoped PAA scope. webpki name-chained the
261 // PAI to this PAA on subject/issuer DN equality alone; it does
262 // NOT interpret the Matter VID OID as a NameConstraint. So when
263 // the anchoring PAA is itself VID-scoped, we must enforce that
264 // its scoped VID equals the DAC/PAI subject VID — otherwise a
265 // VID-scoped PAA could anchor a chain for a different vendor.
266 // A non-VID-scoped PAA (subject_vid == None) imposes no
267 // constraint. `dac_vid == pai_vid` was already established in
268 // step 5, so comparing against `dac_vid` covers both.
269 if let Some(paa_vid) = anchoring_paa.subject_vid() {
270 if paa_vid != dac_vid {
271 return Err(AttestationError::PaaVidScopeMismatch { paa_vid, dac_vid });
272 }
273 }
274
275 Ok(ChainVerification {
276 vendor_id: dac_vid,
277 product_id: dac.subject_pid(),
278 dac_public_key: dac.public_key().to_vec(),
279 paa_subject,
280 paa_skid: anchoring_paa.subject_key_identifier(),
281 })
282}
283
284/// Strip a DER `SEQUENCE` tag-and-length wrapper from `bytes`,
285/// returning the inner contents.
286///
287/// Used to align an x509-parser-produced `Name` DER (full SEQUENCE
288/// with tag + length + contents) against webpki's `TrustAnchor::subject`
289/// (only the contents, the tag/length having been stripped during
290/// anchor extraction). Returns `None` if `bytes` is not a definite-length
291/// `SEQUENCE` or if the declared length runs past the slice — both
292/// indicate input that already failed earlier DER parsing, so the
293/// caller treats them as `UntrustedRoot`.
294///
295/// Handles the two length encodings observed in practice for Matter
296/// `Name`s: short-form (single length byte, content < 128 bytes) and
297/// long-form `0x81`/`0x82` (one or two length bytes, content up to
298/// 65 535 bytes). Longer forms are rejected — a Matter `Name` would
299/// never exceed a few hundred bytes.
300fn strip_sequence_wrapper(bytes: &[u8]) -> Option<&[u8]> {
301 // SEQUENCE constructed: tag byte 0x30.
302 let (&tag, rest) = bytes.split_first()?;
303 if tag != 0x30 {
304 return None;
305 }
306 let (&first_len_byte, after_first) = rest.split_first()?;
307 let (content_len, header_bytes) = match first_len_byte {
308 // Short form: top bit clear, value is the length itself.
309 n if n < 0x80 => (n as usize, 0_usize),
310 // Long form: 0x81 = 1 length byte, 0x82 = 2 length bytes.
311 0x81 => {
312 let (&len, _) = after_first.split_first()?;
313 (len as usize, 1)
314 }
315 0x82 => {
316 let len_bytes: &[u8; 2] = after_first.get(..2)?.try_into().ok()?;
317 (u16::from_be_bytes(*len_bytes) as usize, 2)
318 }
319 // 0x80 (indefinite-length) and 0x83+ (>= 16 MiB) are out of
320 // scope for Matter Names.
321 _ => return None,
322 };
323 let content_start = 2 + header_bytes;
324 let content_end = content_start.checked_add(content_len)?;
325 bytes.get(content_start..content_end)
326}
327
328#[cfg(test)]
329mod tests {
330 // The synthetic-chain helpers pair `dac_vid`/`paa_vid`,
331 // `dac_der`/`pai_der`/`paa_der`, etc. — the near-identical names mirror
332 // the PKI roles by design. Same carve-out as `tests/support/mod.rs`.
333 #![allow(clippy::similar_names, clippy::struct_field_names)]
334
335 use super::*;
336 use crate::attestation::PaaTrustStore;
337
338 const HAPPY_DAC: &[u8] = include_bytes!(
339 "../../../../test-vectors/certs/attestation/happy-path/Chip-Test-DAC-FFF1-8000-0004-Cert.der"
340 );
341 const HAPPY_PAI: &[u8] = include_bytes!(
342 "../../../../test-vectors/certs/attestation/happy-path/Chip-Test-PAI-FFF1-8000-Cert.der"
343 );
344
345 #[test]
346 #[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
347 fn paa_to_trust_anchor_works_on_bundled_csa_root() {
348 let store = PaaTrustStore::with_example_device_roots();
349 let paa = store.iter().next().unwrap();
350 // Must not error; webpki should accept any well-formed
351 // X.509v3 self-signed cert that Paa::from_der accepted.
352 let _anchor = paa_to_trust_anchor(paa).unwrap();
353 }
354
355 #[test]
356 #[allow(clippy::expect_used, clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
357 fn verify_chain_happy_path_on_csa_test_vectors() {
358 let dac = Dac::from_der(HAPPY_DAC).unwrap();
359 let pai = Pai::from_der(HAPPY_PAI).unwrap();
360 let store = PaaTrustStore::with_example_device_roots();
361
362 // CSA test DAC issued ~2022 with multi-year validity; 2024
363 // sits safely inside the window. Pinning the clock keeps the
364 // test deterministic regardless of when it runs.
365 let at = MatterTime::from_unix_secs(1_704_067_200); // 2024-01-01
366
367 let result = verify_chain(&dac, &pai, &store, at).expect("happy-path verify_chain");
368 assert_eq!(result.vendor_id, VendorId::new(0xFFF1));
369 assert_eq!(result.product_id, ProductId::new(0x8000));
370 assert_eq!(result.dac_public_key.len(), 65);
371 assert!(!result.paa_subject.is_empty());
372 }
373
374 // ── Fix A — VID-scoped PAA scope (Matter §6.2.2.1) ──────────────────────
375 //
376 // These tests synthesise a fresh DAC → PAI → PAA chain whose subject
377 // VIDs we control independently, using the same recipe as the
378 // integration-test `build_mock_device_pki` helper (the chip
379 // `gen-negative-fixtures.py` extension layout). They exercise the
380 // step-7 overlay added in this task:
381 //
382 // - VID-scoped PAA whose scope MATCHES the DAC/PAI VID → accepted.
383 // - VID-scoped PAA whose scope DIFFERS from the DAC/PAI VID →
384 // PaaVidScopeMismatch (pre-fix this wrongly passed, because webpki
385 // name-chains on DN equality and never reads the Matter VID OID as
386 // a NameConstraint).
387 // - Non-VID-scoped PAA (subject_vid None) → accepted regardless of
388 // the DAC/PAI VID.
389
390 use matter_cert::test_support::{build_x509_der, TestCertFields};
391 use matter_cert::{
392 BasicConstraints, DistinguishedName, DnAttribute, Extensions, KeyUsage, Signature,
393 };
394 use matter_crypto::{CaseSigner as _, RingSigner};
395
396 /// EKU compact integer for `id-kp-clientAuth` (OID 1.3.6.1.5.5.7.3.2),
397 /// the EKU `verify_chain` requires on the DAC. Matches the value the
398 /// matter-cert X.509 encoder maps to clientAuth.
399 const EKU_CLIENT_AUTH: u32 = 2;
400
401 /// A synthetic DAC → PAI → PAA chain with independently chosen VIDs,
402 /// returned as raw DER for feeding into `verify_chain`.
403 struct SyntheticChain {
404 dac_der: Vec<u8>,
405 pai_der: Vec<u8>,
406 paa_der: Vec<u8>,
407 }
408
409 /// Build a synthetic chain. `paa_vid == None` produces a non-VID-scoped
410 /// PAA; `Some(v)` scopes the PAA subject to VID `v`. The PAI and DAC are
411 /// always scoped to `device_vid`; the DAC also carries `device_pid`.
412 ///
413 /// Validity windows bracket `at_unix` exactly as `build_mock_device_pki`
414 /// does, so the resulting chain validates at that instant.
415 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
416 fn build_synthetic_chain(
417 at_unix: u64,
418 paa_vid: Option<u16>,
419 device_vid: u16,
420 device_pid: u16,
421 ) -> SyntheticChain {
422 // PAA: self-signed root, optionally VID-scoped.
423 let (paa_signer, paa_pkcs8) = RingSigner::generate().expect("PAA key");
424 let mut paa_attrs = vec![DnAttribute::CommonName("Synthetic Test PAA".into())];
425 if let Some(v) = paa_vid {
426 paa_attrs.push(DnAttribute::VendorId(v));
427 }
428 let paa_dn = DistinguishedName::new(paa_attrs);
429 let paa_der = build_x509_der(
430 TestCertFields {
431 serial: vec![0x01],
432 issuer: paa_dn.clone(),
433 not_before: MatterTime::from_unix_secs(at_unix.saturating_sub(365 * 86_400)),
434 not_after: MatterTime::from_unix_secs(at_unix.saturating_add(3650 * 86_400)),
435 subject: paa_dn.clone(),
436 public_key: paa_signer.public_key().clone(),
437 extensions: Extensions::builder()
438 .basic_constraints(Some(BasicConstraints::new(true, Some(1))))
439 .key_usage(Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN))
440 .build(),
441 signature: Signature::new([0u8; 64]),
442 },
443 &paa_pkcs8,
444 )
445 .expect("PAA DER");
446
447 // PAI: signed by PAA, scoped to device_vid.
448 let (pai_signer, pai_pkcs8) = RingSigner::generate().expect("PAI key");
449 let pai_dn = DistinguishedName::new(vec![
450 DnAttribute::CommonName("Synthetic Test PAI".into()),
451 DnAttribute::VendorId(device_vid),
452 ]);
453 let pai_der = build_x509_der(
454 TestCertFields {
455 serial: vec![0x02],
456 issuer: paa_dn,
457 not_before: MatterTime::from_unix_secs(at_unix.saturating_sub(180 * 86_400)),
458 not_after: MatterTime::from_unix_secs(at_unix.saturating_add(1825 * 86_400)),
459 subject: pai_dn.clone(),
460 public_key: pai_signer.public_key().clone(),
461 extensions: Extensions::builder()
462 .basic_constraints(Some(BasicConstraints::new(true, Some(0))))
463 .key_usage(Some(KeyUsage::KEY_CERT_SIGN | KeyUsage::CRL_SIGN))
464 .build(),
465 signature: Signature::new([0u8; 64]),
466 },
467 &paa_pkcs8,
468 )
469 .expect("PAI DER");
470
471 // DAC: leaf, signed by PAI, scoped to device_vid + device_pid.
472 let (dac_signer, _dac_pkcs8) = RingSigner::generate().expect("DAC key");
473 let dac_dn = DistinguishedName::new(vec![
474 DnAttribute::CommonName("Synthetic Test DAC".into()),
475 DnAttribute::VendorId(device_vid),
476 DnAttribute::ProductId(device_pid),
477 ]);
478 let dac_der = build_x509_der(
479 TestCertFields {
480 serial: vec![0x03],
481 issuer: pai_dn,
482 not_before: MatterTime::from_unix_secs(at_unix.saturating_sub(30 * 86_400)),
483 not_after: MatterTime::from_unix_secs(at_unix.saturating_add(365 * 86_400)),
484 subject: dac_dn,
485 public_key: dac_signer.public_key().clone(),
486 extensions: Extensions::builder()
487 .basic_constraints(Some(BasicConstraints::new(false, None)))
488 .key_usage(Some(KeyUsage::DIGITAL_SIGNATURE))
489 .extended_key_usage(Some(vec![EKU_CLIENT_AUTH]))
490 .build(),
491 signature: Signature::new([0u8; 64]),
492 },
493 &pai_pkcs8,
494 )
495 .expect("DAC DER");
496
497 SyntheticChain {
498 dac_der,
499 pai_der,
500 paa_der,
501 }
502 }
503
504 /// Build a single-PAA trust store from the synthetic PAA DER.
505 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
506 fn store_with(paa_der: &[u8]) -> PaaTrustStore {
507 let mut store = PaaTrustStore::empty();
508 store.add(Paa::from_der(paa_der).expect("synthetic PAA parses"));
509 store
510 }
511
512 const SYNTH_AT_UNIX: u64 = 1_800_000_000; // ~2027-01-15, inside every window.
513
514 #[test]
515 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
516 fn vid_scoped_paa_matching_device_vid_is_accepted() {
517 // VID-scoped PAA (0xFFF1) anchoring a 0xFFF1 device → no mismatch.
518 let chain = build_synthetic_chain(SYNTH_AT_UNIX, Some(0xFFF1), 0xFFF1, 0x8001);
519 let dac = Dac::from_der(&chain.dac_der).expect("DAC parses");
520 let pai = Pai::from_der(&chain.pai_der).expect("PAI parses");
521 let store = store_with(&chain.paa_der);
522 let at = MatterTime::from_unix_secs(SYNTH_AT_UNIX);
523
524 let result =
525 verify_chain(&dac, &pai, &store, at).expect("matching VID-scoped PAA accepted");
526 assert_eq!(result.vendor_id, VendorId::new(0xFFF1));
527 }
528
529 #[test]
530 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
531 fn vid_scoped_paa_mismatched_device_vid_is_rejected() {
532 // VID-scoped PAA (0xFFF2) anchoring a 0xFFF1 device. webpki accepts
533 // the DN-chained path (the VID OID is opaque to it), so the §6.2.2.1
534 // overlay must reject. Pre-fix this wrongly passed.
535 let chain = build_synthetic_chain(SYNTH_AT_UNIX, Some(0xFFF2), 0xFFF1, 0x8001);
536 let dac = Dac::from_der(&chain.dac_der).expect("DAC parses");
537 let pai = Pai::from_der(&chain.pai_der).expect("PAI parses");
538 let store = store_with(&chain.paa_der);
539 let at = MatterTime::from_unix_secs(SYNTH_AT_UNIX);
540
541 let err = verify_chain(&dac, &pai, &store, at)
542 .expect_err("VID-scoped PAA anchoring a different vendor must be rejected");
543 assert!(
544 matches!(
545 err,
546 AttestationError::PaaVidScopeMismatch {
547 paa_vid,
548 dac_vid,
549 } if paa_vid == VendorId::new(0xFFF2) && dac_vid == VendorId::new(0xFFF1)
550 ),
551 "expected PaaVidScopeMismatch {{ paa_vid: FFF2, dac_vid: FFF1 }}, got {err:?}"
552 );
553 }
554
555 #[test]
556 #[allow(clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
557 fn non_vid_scoped_paa_imposes_no_vid_constraint() {
558 // Non-VID-scoped PAA anchoring a 0xFFF1 device → accepted; the
559 // overlay short-circuits on subject_vid() == None.
560 let chain = build_synthetic_chain(SYNTH_AT_UNIX, None, 0xFFF1, 0x8001);
561 let dac = Dac::from_der(&chain.dac_der).expect("DAC parses");
562 let pai = Pai::from_der(&chain.pai_der).expect("PAI parses");
563 let store = store_with(&chain.paa_der);
564 let at = MatterTime::from_unix_secs(SYNTH_AT_UNIX);
565
566 let result =
567 verify_chain(&dac, &pai, &store, at).expect("non-VID-scoped PAA imposes no constraint");
568 assert_eq!(result.vendor_id, VendorId::new(0xFFF1));
569 }
570}