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** roots. Suitable for CSA-test
25    /// devices and the hermetic loopback; real certified devices need
26    /// [`Self::from_dirs`] pointed at production roots.
27    #[must_use]
28    pub fn csa_test_roots() -> Self {
29        Self {
30            paa: PaaTrustStore::with_csa_test_roots(),
31            cd: CdSigningRoots::with_csa_test_roots(),
32        }
33    }
34
35    /// Load PAA roots from a directory of `.der` certificates and CD signing
36    /// roots from a directory (or single file) of `.der` certificates — the
37    /// production path (e.g. connectedhomeip's `credentials/production/...`).
38    ///
39    /// # Errors
40    ///
41    /// Returns [`Error::Trust`] if a directory cannot be read or a certificate
42    /// fails to parse.
43    pub fn from_dirs(paa_dir: &Path, cd_dir: &Path) -> Result<Self, Error> {
44        let mut paa = PaaTrustStore::empty();
45        for entry in
46            std::fs::read_dir(paa_dir).map_err(|e| Error::Trust(format!("paa dir: {e}")))?
47        {
48            let path = entry
49                .map_err(|e| Error::Trust(format!("paa entry: {e}")))?
50                .path();
51            if path.extension().and_then(|x| x.to_str()) != Some("der") {
52                continue;
53            }
54            let der = std::fs::read(&path).map_err(|e| Error::Trust(format!("paa read: {e}")))?;
55            let cert = matter_commissioning::Paa::from_der(&der)
56                .map_err(|e| Error::Trust(format!("paa parse {}: {e:?}", path.display())))?;
57            paa.add(cert);
58        }
59
60        let mut cd_ders: Vec<Vec<u8>> = Vec::new();
61        if cd_dir.is_dir() {
62            for entry in
63                std::fs::read_dir(cd_dir).map_err(|e| Error::Trust(format!("cd dir: {e}")))?
64            {
65                let path = entry
66                    .map_err(|e| Error::Trust(format!("cd entry: {e}")))?
67                    .path();
68                if path.extension().and_then(|x| x.to_str()) != Some("der") {
69                    continue;
70                }
71                cd_ders
72                    .push(std::fs::read(&path).map_err(|e| Error::Trust(format!("cd read: {e}")))?);
73            }
74        } else {
75            cd_ders.push(std::fs::read(cd_dir).map_err(|e| Error::Trust(format!("cd read: {e}")))?);
76        }
77        let refs: Vec<&[u8]> = cd_ders.iter().map(Vec::as_slice).collect();
78        let cd = CdSigningRoots::from_cert_der(&refs)
79            .map_err(|e| Error::Trust(format!("cd parse: {e:?}")))?;
80
81        Ok(Self { paa, cd })
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md allows unwrap/expect with justification.
87mod tests {
88    use super::*;
89
90    #[test]
91    fn csa_test_roots_constructs() {
92        let _trust = AttestationTrust::csa_test_roots();
93        // Construction succeeds and yields usable PAA + CD stores; deeper
94        // verification is covered by matter-commissioning's attestation tests.
95    }
96
97    #[test]
98    fn from_dirs_errors_on_missing_dir() {
99        let err = AttestationTrust::from_dirs(
100            Path::new("/nonexistent/paa"),
101            Path::new("/nonexistent/cd"),
102        )
103        .expect_err("missing dir must error");
104        assert!(matches!(err, Error::Trust(_)));
105    }
106
107    /// `from_dirs` must skip non-`.der` files (`.pem`, `.txt`, etc.) in
108    /// both the PAA and CD directories.  The connectedhomeip
109    /// `credentials/development/{paa-root-certs,cd-certs}` directories
110    /// contain `.pem` files alongside each `.der`; without the extension
111    /// filter, `from_dirs` errors trying to parse PEM as DER.
112    ///
113    /// Test strategy:
114    /// - Write a real PAA root DER into a temp PAA dir alongside a junk
115    ///   `.pem` and a `.txt`.
116    /// - Write a real X.509 P-256 cert DER into a temp CD dir alongside
117    ///   the same junk files.
118    /// - Assert that `from_dirs` succeeds (junk files were skipped) and
119    ///   that each store contains exactly 1 entry.
120    ///
121    /// Temp dirs are created under `target/` so they stay out of the
122    /// source tree and survive interrupted runs gracefully (the directory
123    /// is cleaned up at the end of the test).
124    ///
125    /// Cert fixtures are read from the in-repo `test-vectors/` tree via
126    /// `CARGO_MANIFEST_DIR` so no extra crate dependency is required.
127    #[test]
128    fn from_dirs_skips_non_der_files() {
129        use std::fs;
130
131        // ── locate in-repo fixtures ────────────────────────────────────────
132        // CARGO_MANIFEST_DIR points to `crates/matter-controller/`.
133        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
134        let repo_root = manifest_dir
135            .parent() // crates/
136            .unwrap()
137            .parent() // matter-rust/
138            .unwrap();
139
140        // PAA cert: a real Matter PAA (no-VID variant) bundled in the
141        // commissioning crate's CSA test-root collection.
142        let paa_der_src = repo_root
143            .join("crates/matter-commissioning/src/attestation/csa_test_roots")
144            .join("Chip-Test-PAA-NoVID-Cert.der");
145        let paa_bytes = fs::read(&paa_der_src).expect("bundled PAA NoVID DER must be readable");
146
147        // CD signing cert: reuse the same PAA cert (any X.509 P-256 cert
148        // satisfies `CdSigningRoots::from_cert_der`; we only need the
149        // extension filter to run, not a real attestation verification).
150        let cd_bytes = paa_bytes.clone();
151
152        // ── build temp directories under target/ ──────────────────────────
153        let target_dir = repo_root.join("target").join("from-dirs-test");
154        let paa_dir = target_dir.join("paa");
155        let cd_dir = target_dir.join("cd");
156        fs::create_dir_all(&paa_dir).expect("create temp PAA dir");
157        fs::create_dir_all(&cd_dir).expect("create temp CD dir");
158
159        // Write the real DER cert into each dir.
160        fs::write(paa_dir.join("test-paa.der"), &paa_bytes).expect("write PAA DER");
161        fs::write(cd_dir.join("test-cd.der"), &cd_bytes).expect("write CD DER");
162
163        // Write junk files alongside — these must be silently skipped.
164        fs::write(paa_dir.join("test-paa.pem"), b"not der at all")
165            .expect("write junk pem in PAA dir");
166        fs::write(paa_dir.join("README.txt"), b"also junk").expect("write junk txt in PAA dir");
167        fs::write(cd_dir.join("test-cd.pem"), b"not der at all").expect("write junk pem in CD dir");
168        fs::write(cd_dir.join("notes.txt"), b"also junk").expect("write junk txt in CD dir");
169
170        // ── exercise `from_dirs` ──────────────────────────────────────────
171        let trust = AttestationTrust::from_dirs(&paa_dir, &cd_dir)
172            .expect("from_dirs must succeed when non-.der files are present");
173
174        // Each dir contained exactly one .der file.
175        assert_eq!(trust.paa.len(), 1, "exactly one PAA loaded");
176        assert_eq!(trust.cd.len(), 1, "exactly one CD signing root loaded");
177
178        // ── clean up ──────────────────────────────────────────────────────
179        // Best-effort: a failure here does not invalidate the test result.
180        let _ = fs::remove_dir_all(&target_dir);
181    }
182}