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}