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}