Skip to main content

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}