matter_commissioning/attestation/error.rs
1//! Error type for the attestation module.
2//!
3//! M6.2.1 shipped only the [`AttestationError::Parse`] variant.
4//! M6.2.2 added six chain-validation outcomes. M6.2.3 adds the single
5//! signature-verification outcome
6//! ([`AttestationError::BadResponseSignature`]).
7
8use thiserror::Error;
9
10use crate::attestation::extensions::VendorId;
11
12/// Errors produced by device attestation verification.
13///
14/// `#[non_exhaustive]` so future phases can add variants without a
15/// breaking change.
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum AttestationError {
19 /// The DER bytes passed to one of [`crate::attestation::x509::Dac`],
20 /// [`crate::attestation::x509::Pai`], or
21 /// [`crate::attestation::x509::Paa`]'s `from_der` constructor failed
22 /// to parse, or failed a Matter-specific subject-DN structural check
23 /// (missing required VID/PID attribute, or — for
24 /// [`crate::attestation::x509::Paa`] — a forbidden PID attribute).
25 #[error("X.509 parse failure")]
26 Parse(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
27
28 /// Path validation rejected the chain for a reason not captured by
29 /// a more specific variant. Sources a boxed `webpki::Error`
30 /// (downcastable via `Error::downcast_ref` on the trait object
31 /// returned by `source()`) so callers who care about the
32 /// underlying webpki kind can still inspect it without our
33 /// public API mentioning webpki by type.
34 #[error("certificate chain validation failed")]
35 InvalidChain(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
36
37 /// One of the certs in the chain was outside its validity window
38 /// at the supplied [`matter_cert::time::MatterTime`].
39 #[error("certificate expired or not yet valid")]
40 TimeBoundsViolation,
41
42 /// A non-CA cert was marked `BasicConstraints.cA = true`, or the
43 /// path-length-constraint was violated.
44 #[error("BasicConstraints violation")]
45 BasicConstraintsViolation,
46
47 /// The certificate breaches the Matter attestation certificate
48 /// *profile* (Matter Core Spec §6.2.2) in a way other than
49 /// `BasicConstraints`: a wrong version or signature algorithm, or a
50 /// `KeyUsage` / `SubjectKeyIdentifier` / `AuthorityKeyIdentifier`
51 /// extension that is absent, mis-flagged (wrong criticality or
52 /// bits), duplicated, or malformed. Mirrors connectedhomeip's
53 /// `VerifyAttestationCertificateFormat`; `rustls-webpki` enforces
54 /// none of these (it ignores `KeyUsage` and never requires
55 /// SKID/AKID), so this check runs in our own code as a peer of
56 /// [`crate::attestation::verify_chain`]. (`BasicConstraints`
57 /// breaches surface as [`AttestationError::BasicConstraintsViolation`].)
58 #[error("attestation certificate format violation: {reason}")]
59 CertFormatViolation {
60 /// Human-readable description of which profile rule failed.
61 reason: &'static str,
62 },
63
64 /// No PAA in the supplied [`crate::attestation::PaaTrustStore`]
65 /// matched the PAI's issuer.
66 #[error("PAA not in trust store")]
67 UntrustedRoot,
68
69 /// DAC subject [`VendorId`] did not equal PAI subject [`VendorId`]
70 /// (Matter §6.2.3 requires equality).
71 #[error("VID mismatch: DAC={dac:?} PAI={pai:?}")]
72 VidMismatch {
73 /// [`VendorId`] observed on the DAC subject.
74 dac: VendorId,
75 /// [`VendorId`] observed on the PAI subject.
76 pai: VendorId,
77 },
78
79 /// PAI is product-scoped (`subject_pid` is `Some`) and its
80 /// [`crate::attestation::ProductId`] differs from the DAC's.
81 /// Matter §6.2.3: a scoped PAI authorises only the matching
82 /// product.
83 #[error("PAI is not authorized for DAC's product")]
84 PaiVidNotAuthorized,
85
86 /// The PAA that anchored the chain is VID-scoped (its subject DN
87 /// carries a [`VendorId`]) but that VID does not equal the DAC/PAI
88 /// subject VID.
89 ///
90 /// Matter Core Spec §6.2.2.1 requires a commissioner to verify that
91 /// a VID-scoped PAA only anchors attestation chains whose DAC and
92 /// PAI subject VID equal the PAA's scoped VID. `rustls-webpki`
93 /// performs only RFC 5280 DN-chaining — it treats the Matter VID
94 /// OID as an opaque DN attribute, not as a `NameConstraint` — so
95 /// without this overlay a VID-scoped PAA could anchor a chain for a
96 /// different vendor. (chip's `DeviceAttestationVerifier` enforces
97 /// the same rule.)
98 #[error("VID-scoped PAA scope mismatch: PAA={paa_vid:?} DAC/PAI={dac_vid:?}")]
99 PaaVidScopeMismatch {
100 /// [`VendorId`] the anchoring PAA is scoped to (its subject DN).
101 paa_vid: VendorId,
102 /// [`VendorId`] observed on the DAC/PAI subject (these two are
103 /// already known equal by the time this check runs).
104 dac_vid: VendorId,
105 },
106
107 /// `attestation_elements` TLV failed to decode or is missing
108 /// required fields (CD bytes, nonce, timestamp).
109 ///
110 /// Returned by
111 /// [`crate::attestation::extract_attestation_elements_fields`] when
112 /// the outer shape is not an anonymous structure, the structure is
113 /// truncated, a required context-tagged field (1 = CD bytes,
114 /// 2 = nonce, 3 = timestamp) is missing or has the wrong wire type,
115 /// the nonce is not exactly 32 bytes, or a required field appears
116 /// more than once.
117 #[error("attestation_elements malformed or missing required fields")]
118 ResponseElementsMalformed,
119
120 /// Certification Declaration (CD) has invalid CMS structure: it
121 /// failed `ContentInfo` / `SignedData` DER parse, declared
122 /// multiple signers, lacked an attached eContent, used an
123 /// unexpected `contentType` / `signatureAlgorithm`, or otherwise
124 /// did not match the Matter Core Spec §6.3.1 shape expected by
125 /// [`crate::attestation::verify_certification_declaration`].
126 #[error("certification declaration has invalid CMS structure")]
127 CertificationDeclarationMalformed,
128
129 /// Certification Declaration signature did not verify against any
130 /// trusted root in the supplied
131 /// [`crate::attestation::CdSigningRoots`] store.
132 #[error("certification declaration signature does not verify against any trusted root")]
133 CertificationDeclarationSignatureInvalid,
134
135 /// Certification Declaration inner TLV (the signed eContent
136 /// payload) is malformed, truncated, or missing a required
137 /// context-tagged field per Matter Core Spec §6.3.1.
138 #[error("certification declaration inner TLV malformed")]
139 CertificationDeclarationTlvMalformed,
140
141 /// Vendor ID declared inside the verified Certification
142 /// Declaration does not equal the VID the caller expected (sourced
143 /// from the verified DAC subject in M6.4.x).
144 #[error(
145 "certification declaration VID mismatch: declared {declared:?}, expected {expected:?}"
146 )]
147 CertificationDeclarationVidMismatch {
148 /// Vendor ID declared inside the CD's inner TLV (tag 1).
149 declared: crate::attestation::VendorId,
150 /// Vendor ID the caller required (typically the DAC subject's VID).
151 expected: crate::attestation::VendorId,
152 },
153
154 /// Product ID list inside the verified Certification Declaration
155 /// does not contain the PID the caller expected.
156 #[error("certification declaration PID list does not contain expected {0:?}")]
157 CertificationDeclarationPidMismatch(crate::attestation::ProductId),
158
159 /// The Certification Declaration carries an `authorized_paa_list`
160 /// (Matter Core Spec §6.3.1 tag 11) that does not include the
161 /// `SubjectKeyIdentifier` of the PAA that anchored the device's DAC
162 /// chain. Per Matter §6.2.3 the device may only be attested under a
163 /// PAA the CD authorizes, so this is a counterfeit-detection reject
164 /// (chip `DefaultDeviceAttestationVerifier.cpp:738`). Also raised when
165 /// the CD scopes its PAAs but the anchoring PAA carries no SKID to
166 /// match.
167 #[error("certification declaration authorized_paa_list does not include the device's PAA")]
168 CertificationDeclarationPaaNotAuthorized,
169
170 /// ECDSA verification of the device's attestation-response signature
171 /// over `attestation_elements || attestation_challenge` did not
172 /// succeed against the DAC public key.
173 ///
174 /// **Deliberately coarse.** Per the M6.2 design (§Error handling —
175 /// information leakage table), this variant does NOT distinguish
176 /// between
177 ///
178 /// - signature bytes corrupted in transit,
179 /// - the device signed with a key other than the DAC's,
180 /// - the wrong `attestation_challenge` was supplied (e.g. a
181 /// replay or session-state mismatch), or
182 /// - `attestation_elements` was tampered.
183 ///
184 /// A more granular surface here would let an attacker probe which
185 /// of these failed, narrowing their guess for the actual session
186 /// challenge.
187 #[error("AttestationResponse signature verification failed")]
188 BadResponseSignature,
189}
190
191/// Map a [`webpki::Error`] kind to our typed [`AttestationError`].
192///
193/// Spec-mandated mapping (see the M6.2 design doc, §Error type —
194/// "Well-known [`webpki::Error`] kinds"), adapted to `webpki 0.103`'s
195/// actual variant set:
196///
197/// | [`webpki::Error`] kind | [`AttestationError`] |
198/// |-------------------------------------------------------------------------------------|-------------------------------|
199/// | `CertExpired{..}`, `CertNotValidYet{..}`, `InvalidCertValidity` | `TimeBoundsViolation` |
200/// | `PathLenConstraintViolated`, `EndEntityUsedAsCa`, `CaUsedAsEndEntity` | `BasicConstraintsViolation` |
201/// | `UnknownIssuer` | `UntrustedRoot` |
202/// | (any other kind) | `InvalidChain(boxed)` |
203///
204/// [`AttestationError::BasicConstraintsViolation`] covers three
205/// distinct `BasicConstraints`-extension errors webpki distinguishes:
206/// - `EndEntityUsedAsCa` — a CA-marked cert was relied on as a leaf
207/// (this is what fires when a DAC has `cA = true`, since webpki then
208/// refuses to use it as the end-entity).
209/// - `CaUsedAsEndEntity` — same family of bug from the other direction.
210/// - `PathLenConstraintViolated` — chain too long for the intermediate's
211/// declared `pathLenConstraint`.
212///
213/// All three are `BasicConstraints` extension semantics per RFC 5280
214/// §4.2.1.9, so they fold into our single typed variant.
215//
216// pub(crate) because the only legitimate caller is chain.rs::verify_chain.
217// Directed tests in chain.rs / this file cover every row of the table.
218pub(crate) fn map_webpki_error(err: webpki::Error) -> AttestationError {
219 use webpki::Error as W;
220 match err {
221 W::CertExpired { .. } | W::CertNotValidYet { .. } | W::InvalidCertValidity => {
222 AttestationError::TimeBoundsViolation
223 }
224 W::PathLenConstraintViolated | W::EndEntityUsedAsCa | W::CaUsedAsEndEntity => {
225 AttestationError::BasicConstraintsViolation
226 }
227 W::UnknownIssuer => AttestationError::UntrustedRoot,
228 other => AttestationError::InvalidChain(Box::new(other)),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use core::time::Duration;
236 use rustls_pki_types::UnixTime;
237
238 fn epoch() -> UnixTime {
239 UnixTime::since_unix_epoch(Duration::from_secs(0))
240 }
241
242 #[test]
243 fn maps_cert_expired_to_time_bounds_violation() {
244 let err = map_webpki_error(webpki::Error::CertExpired {
245 time: epoch(),
246 not_after: epoch(),
247 });
248 assert!(matches!(err, AttestationError::TimeBoundsViolation));
249 }
250
251 #[test]
252 fn maps_cert_not_valid_yet_to_time_bounds_violation() {
253 let err = map_webpki_error(webpki::Error::CertNotValidYet {
254 time: epoch(),
255 not_before: epoch(),
256 });
257 assert!(matches!(err, AttestationError::TimeBoundsViolation));
258 }
259
260 #[test]
261 fn maps_invalid_cert_validity_to_time_bounds_violation() {
262 let err = map_webpki_error(webpki::Error::InvalidCertValidity);
263 assert!(matches!(err, AttestationError::TimeBoundsViolation));
264 }
265
266 #[test]
267 fn maps_path_len_constraint_violated_to_basic_constraints_violation() {
268 let err = map_webpki_error(webpki::Error::PathLenConstraintViolated);
269 assert!(matches!(err, AttestationError::BasicConstraintsViolation));
270 }
271
272 #[test]
273 fn maps_end_entity_used_as_ca_to_basic_constraints_violation() {
274 let err = map_webpki_error(webpki::Error::EndEntityUsedAsCa);
275 assert!(matches!(err, AttestationError::BasicConstraintsViolation));
276 }
277
278 #[test]
279 fn maps_ca_used_as_end_entity_to_basic_constraints_violation() {
280 let err = map_webpki_error(webpki::Error::CaUsedAsEndEntity);
281 assert!(matches!(err, AttestationError::BasicConstraintsViolation));
282 }
283
284 #[test]
285 fn maps_unknown_issuer_to_untrusted_root() {
286 let err = map_webpki_error(webpki::Error::UnknownIssuer);
287 assert!(matches!(err, AttestationError::UntrustedRoot));
288 }
289
290 #[test]
291 fn maps_long_tail_to_invalid_chain() {
292 // Pick a kind that's NOT in the mapping table — signature
293 // failure is a representative member of the "everything else"
294 // bucket.
295 let err = map_webpki_error(webpki::Error::InvalidSignatureForPublicKey);
296 assert!(matches!(err, AttestationError::InvalidChain(_)));
297 }
298
299 #[test]
300 fn bad_response_signature_variant_exists() {
301 // Construction smoke test: this variant must be a unit variant so
302 // it carries no information beyond "verification failed" — see
303 // M6.2 design §Error handling: the single coarse variant prevents
304 // the error channel from leaking which secret (key, challenge,
305 // elements, or signature) was off.
306 let err = AttestationError::BadResponseSignature;
307 assert!(matches!(err, AttestationError::BadResponseSignature));
308 // Display string covers what the operator will see; assert on its
309 // exact text so a future rename breaks a test, not a log.
310 assert_eq!(
311 format!("{err}"),
312 "AttestationResponse signature verification failed"
313 );
314 }
315}