Skip to main content

matter_controller/
trust.rs

1//! Device-attestation trust material: the PAA roots that anchor DAC/PAI chain
2//! validation and the CD signing roots that anchor Certification-Declaration
3//! signatures. Configured once on the controller (attestation is a fabric-wide
4//! security policy — chip holds it on the commissioner the same way).
5//!
6//! This is a concrete value type for v1.0. When ledger-backed sourcing (DCL)
7//! lands post-1.0, a `trait AttestationVerifier` can emerge here without an
8//! API break to the commissioning entry point.
9
10use std::path::Path;
11
12use matter_commissioning::{CdSigningRoots, PaaTrustStore};
13
14use crate::error::Error;
15
16/// The trust anchors used to verify a device during commissioning.
17#[derive(Debug)]
18pub struct AttestationTrust {
19    pub(crate) paa: PaaTrustStore,
20    pub(crate) cd: CdSigningRoots,
21}
22
23impl AttestationTrust {
24    /// Construct from the bundled CSA **test / example** roots: the PAA
25    /// test roots plus the CD signing roots that verify chip's example
26    /// devices — the synthetic loopback root, chip's test CD signing
27    /// authority, and CSA production "CD Signing Key 001" (which signs the
28    /// VID=0xFFF1 CD served by every example-DAC device, including the
29    /// esp-matter ESP32-C6). Suitable for CSA-test / dev / example devices
30    /// and the hermetic loopback.
31    ///
32    /// This is **not** the full CSA production trust set: an arbitrary
33    /// certified product may present a DAC chained to a PAA, or a CD signed
34    /// by a CSA production key, that is not bundled here. Commissioners for
35    /// arbitrary certified devices use [`Self::from_dirs`] pointed at the
36    /// production roots.
37    #[must_use]
38    pub fn example_device_roots() -> Self {
39        Self {
40            paa: PaaTrustStore::with_example_device_roots(),
41            cd: CdSigningRoots::with_example_device_roots(),
42        }
43    }
44
45    /// Load PAA roots from a directory of `.der` certificates and CD signing
46    /// roots from a directory (or single file) of `.der` certificates — the
47    /// production path (e.g. connectedhomeip's `credentials/production/...`).
48    ///
49    /// # Errors
50    ///
51    /// Returns [`Error::Trust`] if a directory cannot be read or a certificate
52    /// fails to parse.
53    pub fn from_dirs(paa_dir: &Path, cd_dir: &Path) -> Result<Self, Error> {
54        let mut paa = PaaTrustStore::empty();
55        for entry in
56            std::fs::read_dir(paa_dir).map_err(|e| Error::Trust(format!("paa dir: {e}")))?
57        {
58            let path = entry
59                .map_err(|e| Error::Trust(format!("paa entry: {e}")))?
60                .path();
61            if path.extension().and_then(|x| x.to_str()) != Some("der") {
62                continue;
63            }
64            let der = std::fs::read(&path).map_err(|e| Error::Trust(format!("paa read: {e}")))?;
65            let cert = matter_commissioning::Paa::from_der(&der)
66                .map_err(|e| Error::Trust(format!("paa parse {}: {e:?}", path.display())))?;
67            paa.add(cert);
68        }
69
70        let mut cd_ders: Vec<Vec<u8>> = Vec::new();
71        if cd_dir.is_dir() {
72            for entry in
73                std::fs::read_dir(cd_dir).map_err(|e| Error::Trust(format!("cd dir: {e}")))?
74            {
75                let path = entry
76                    .map_err(|e| Error::Trust(format!("cd entry: {e}")))?
77                    .path();
78                if path.extension().and_then(|x| x.to_str()) != Some("der") {
79                    continue;
80                }
81                cd_ders
82                    .push(std::fs::read(&path).map_err(|e| Error::Trust(format!("cd read: {e}")))?);
83            }
84        } else {
85            cd_ders.push(std::fs::read(cd_dir).map_err(|e| Error::Trust(format!("cd read: {e}")))?);
86        }
87        let refs: Vec<&[u8]> = cd_ders.iter().map(Vec::as_slice).collect();
88        let cd = CdSigningRoots::from_cert_der(&refs)
89            .map_err(|e| Error::Trust(format!("cd parse: {e:?}")))?;
90
91        Ok(Self { paa, cd })
92    }
93}
94
95#[cfg(test)]
96#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md allows unwrap/expect with justification.
97mod tests {
98    use super::*;
99
100    #[test]
101    fn example_device_roots_constructs() {
102        let _trust = AttestationTrust::example_device_roots();
103        // Construction succeeds and yields usable PAA + CD stores; deeper
104        // verification is covered by matter-commissioning's attestation tests.
105    }
106
107    #[test]
108    fn from_dirs_errors_on_missing_dir() {
109        let err = AttestationTrust::from_dirs(
110            Path::new("/nonexistent/paa"),
111            Path::new("/nonexistent/cd"),
112        )
113        .expect_err("missing dir must error");
114        assert!(matches!(err, Error::Trust(_)));
115    }
116
117    /// `from_dirs` must skip non-`.der` files (`.pem`, `.txt`, etc.) in
118    /// both the PAA and CD directories.  The connectedhomeip
119    /// `credentials/development/{paa-root-certs,cd-certs}` directories
120    /// contain `.pem` files alongside each `.der`; without the extension
121    /// filter, `from_dirs` errors trying to parse PEM as DER.
122    ///
123    /// Test strategy:
124    /// - Write a real PAA root DER into a temp PAA dir alongside a junk
125    ///   `.pem` and a `.txt`.
126    /// - Write a real X.509 P-256 cert DER into a temp CD dir alongside
127    ///   the same junk files.
128    /// - Assert that `from_dirs` succeeds (junk files were skipped) and
129    ///   that each store contains exactly 1 entry.
130    ///
131    /// Temp dirs are created under `target/` so they stay out of the
132    /// source tree and survive interrupted runs gracefully (the directory
133    /// is cleaned up at the end of the test).
134    ///
135    /// Cert fixtures are read from the in-repo `test-vectors/` tree via
136    /// `CARGO_MANIFEST_DIR` so no extra crate dependency is required.
137    #[test]
138    fn from_dirs_skips_non_der_files() {
139        use std::fs;
140
141        // ── locate in-repo fixtures ────────────────────────────────────────
142        // CARGO_MANIFEST_DIR points to `crates/matter-controller/`.
143        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
144        let repo_root = manifest_dir
145            .parent() // crates/
146            .unwrap()
147            .parent() // matter-rust/
148            .unwrap();
149
150        // PAA cert: a real Matter PAA (no-VID variant) bundled in the
151        // commissioning crate's CSA test-root collection.
152        let paa_der_src = repo_root
153            .join("crates/matter-commissioning/src/attestation/csa_test_roots")
154            .join("Chip-Test-PAA-NoVID-Cert.der");
155        let paa_bytes = fs::read(&paa_der_src).expect("bundled PAA NoVID DER must be readable");
156
157        // CD signing cert: reuse the same PAA cert (any X.509 P-256 cert
158        // satisfies `CdSigningRoots::from_cert_der`; we only need the
159        // extension filter to run, not a real attestation verification).
160        let cd_bytes = paa_bytes.clone();
161
162        // ── build temp directories under target/ ──────────────────────────
163        let target_dir = repo_root.join("target").join("from-dirs-test");
164        let paa_dir = target_dir.join("paa");
165        let cd_dir = target_dir.join("cd");
166        fs::create_dir_all(&paa_dir).expect("create temp PAA dir");
167        fs::create_dir_all(&cd_dir).expect("create temp CD dir");
168
169        // Write the real DER cert into each dir.
170        fs::write(paa_dir.join("test-paa.der"), &paa_bytes).expect("write PAA DER");
171        fs::write(cd_dir.join("test-cd.der"), &cd_bytes).expect("write CD DER");
172
173        // Write junk files alongside — these must be silently skipped.
174        fs::write(paa_dir.join("test-paa.pem"), b"not der at all")
175            .expect("write junk pem in PAA dir");
176        fs::write(paa_dir.join("README.txt"), b"also junk").expect("write junk txt in PAA dir");
177        fs::write(cd_dir.join("test-cd.pem"), b"not der at all").expect("write junk pem in CD dir");
178        fs::write(cd_dir.join("notes.txt"), b"also junk").expect("write junk txt in CD dir");
179
180        // ── exercise `from_dirs` ──────────────────────────────────────────
181        let trust = AttestationTrust::from_dirs(&paa_dir, &cd_dir)
182            .expect("from_dirs must succeed when non-.der files are present");
183
184        // Each dir contained exactly one .der file.
185        assert_eq!(trust.paa.len(), 1, "exactly one PAA loaded");
186        assert_eq!(trust.cd.len(), 1, "exactly one CD signing root loaded");
187
188        // ── clean up ──────────────────────────────────────────────────────
189        // Best-effort: a failure here does not invalidate the test result.
190        let _ = fs::remove_dir_all(&target_dir);
191    }
192}