attestation_validator/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use std::{
5    collections::HashMap,
6    io::{BufReader, Read, Seek},
7};
8
9use x509_parser::{
10    error::X509Error,
11    pem::Pem,
12    prelude::{FromDer, X509Certificate},
13};
14
15/// Error when parsing artifacts or performing validation.
16#[non_exhaustive]
17#[derive(Debug, thiserror::Error)]
18pub enum Error {
19    /// X509 error.
20    #[error("X509 error")]
21    X509(#[from] x509_parser::error::X509Error),
22
23    /// PEM format error.
24    #[error("PEM error")]
25    Pem(#[from] x509_parser::error::PEMError),
26
27    /// Expected OID has not been found.
28    #[error("Expected to find OID {oid} but it is missing")]
29    OidMissing {
30        /// The OID that was expected.
31        oid: String,
32    },
33
34    /// Unexpected field value.
35    #[error("Field has unexpected value")]
36    UnexpectedFieldValue {
37        /// Field description.
38        field: String,
39
40        /// Expected value.
41        expected: String,
42
43        /// Actual value.
44        actual: String,
45    },
46
47    /// Expected to find a certificate.
48    #[error("Certificate is missing")]
49    CertificateMissing,
50}
51
52impl From<x509_parser::asn1_rs::Err<x509_parser::error::X509Error>> for Error {
53    fn from(value: x509_parser::asn1_rs::Err<x509_parser::error::X509Error>) -> Self {
54        use x509_parser::nom::Err;
55        let inner = match value {
56            Err::Incomplete(_) => X509Error::Generic,
57            Err::Error(e) | Err::Failure(e) => e,
58        };
59        Self::X509(inner)
60    }
61}
62
63/// Library-specific result type.
64pub type Result<T> = std::result::Result<T, Error>;
65
66/// Attestation validator.
67#[derive(Default, Debug)]
68pub struct Validator {
69    cert_chain: Vec<OwnedCert>,
70}
71
72#[derive(Debug)]
73struct OwnedCert {
74    contents: Vec<u8>,
75}
76
77impl OwnedCert {
78    fn new(contents: Vec<u8>) -> Self {
79        Self { contents }
80    }
81}
82
83impl Validator {
84    fn add_and_validate(&mut self, der: Vec<u8>) -> Result<()> {
85        if let Some(parent) = self.cert_chain.last() {
86            let parent = X509Certificate::from_der(&parent.contents)?.1;
87            let current = X509Certificate::from_der(&der)?.1;
88            current.verify_signature(Some(parent.public_key()))?;
89        }
90        self.cert_chain.push(OwnedCert::new(der));
91        Ok(())
92    }
93
94    /// Adds one or more PEM-encoded certificates from the reader to the certificate chain.
95    ///
96    /// Adding certificate triggers chain validation.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if PEM parsing fails or certificate chain validation fails.
101    pub fn add_from_pem(&mut self, reader: impl Read + Seek) -> Result<()> {
102        for pem in Pem::iter_from_reader(BufReader::new(reader)) {
103            let pem = pem?;
104            self.add_and_validate(pem.contents)?;
105        }
106        Ok(())
107    }
108
109    /// Add one raw, binary DER-encoded certificate to the certificate chain.
110    ///
111    /// Adding certificate triggers chain validation.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if DER parsing fails or certificate chain validation fails.
116    pub fn add_from_der(&mut self, der: Vec<u8>) -> Result<()> {
117        self.add_and_validate(der)?;
118        Ok(())
119    }
120
121    /// Returns [extensions][Extensions] present in the last certificate in the chain (leaf).
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if DER parsing of the last certificate fails, if there are no certificates in the chain or no extensions.
126    pub fn leaf_extensions(&self) -> Result<Extensions> {
127        let leaf = self.cert_chain.last().ok_or(Error::CertificateMissing)?;
128        let leaf = X509Certificate::from_der(&leaf.contents)?.1;
129        let ext = leaf.extensions_map()?;
130        Ok(Extensions(
131            ext.into_iter()
132                .map(|(k, v)| (k.to_string(), v.value.to_vec()))
133                .collect(),
134        ))
135    }
136}
137
138macro_rules! check_eq {
139    ($actual:expr, $expected:expr, $descr:tt) => {
140        match (&$actual, &$expected, &$descr) {
141            (actual, expected, descr) => {
142                if actual != expected {
143                    return Err(Error::UnexpectedFieldValue {
144                        field: (*descr).into(),
145                        expected: (*expected).to_string(),
146                        actual: (*actual).to_string(),
147                    });
148                }
149            }
150        }
151    };
152}
153
154/// Represents certificate extensions.
155#[derive(Debug)]
156pub struct Extensions(HashMap<String, Vec<u8>>);
157
158impl Extensions {
159    /// Return inner object holding a map from stringified OIDs to their raw values.
160    #[must_use]
161    pub fn into_inner(self) -> HashMap<String, Vec<u8>> {
162        self.0
163    }
164
165    /// Return a value of the extension if it is present or an error otherwise.
166    ///
167    /// # Errors
168    ///
169    /// If the value for given `oid` is missing this function returns [`Error::OidMissing`].
170    pub fn get(&self, oid: &str) -> Result<&[u8]> {
171        self.0
172            .get(oid)
173            .map(|v| &v[..])
174            .ok_or_else(|| Error::OidMissing { oid: oid.into() })
175    }
176
177    /// Returns an attestation object specific to YubiHSM.
178    ///
179    /// # Errors
180    ///
181    /// If the value for given `oid` is missing this function returns [`Error::OidMissing`].
182    /// If field values have unexpected values [`Error::UnexpectedFieldValue`] is returned.
183    pub fn to_yubihsm_attestation(&self) -> Result<YubiHsmAttestation> {
184        let firmware_version = self.get("1.3.6.1.4.1.41482.4.1")?;
185        check_eq!(firmware_version[0], 4, "octet string");
186        check_eq!(firmware_version[1], 3, "length");
187        let firmware_version = (
188            firmware_version[2],
189            firmware_version[3],
190            firmware_version[4],
191        );
192
193        let serial_number = self.get("1.3.6.1.4.1.41482.4.2")?;
194        check_eq!(serial_number[0], 2, "integer");
195        check_eq!(serial_number[1], 4, "length");
196        let mut sn: [u8; 4] = [0; 4];
197        sn.copy_from_slice(&serial_number[2..6]);
198        let serial_number = u32::from_be_bytes(sn);
199
200        let origin = self.get("1.3.6.1.4.1.41482.4.3")?;
201        check_eq!(origin[0], 3, "bit string");
202        check_eq!(origin[1], 2, "length");
203        check_eq!(origin[2], 0, "padding");
204        let origin = origin[3];
205
206        let domains = self.get("1.3.6.1.4.1.41482.4.4")?;
207        check_eq!(domains[0], 3, "bit string");
208        check_eq!(domains[1], 3, "length");
209        check_eq!(domains[2], 0, "padding");
210        let domains = u16::from_be_bytes([domains[3], domains[4]]);
211
212        let capabilities = self.get("1.3.6.1.4.1.41482.4.5")?;
213        check_eq!(capabilities[0], 3, "bit string");
214        check_eq!(capabilities[1], 9, "length");
215        check_eq!(capabilities[2], 0, "padding");
216        let mut caps = [0; 8];
217        caps.copy_from_slice(&capabilities[3..]);
218
219        let object_id = self.get("1.3.6.1.4.1.41482.4.6")?;
220        check_eq!(object_id[0], 2, "integer");
221        check_eq!(object_id[1], 1, "length");
222        let object_id = u16::from_be_bytes([0, object_id[2]]);
223
224        let label = self.get("1.3.6.1.4.1.41482.4.9")?;
225        check_eq!(label[0], 12, "utf-8 string");
226        let label = String::from_utf8_lossy(&label[2..]).into_owned();
227
228        Ok(YubiHsmAttestation {
229            firmware_version,
230            serial_number,
231            origin,
232            domains,
233            capabilities: caps,
234            object_id,
235            label,
236        })
237    }
238}
239
240/// YubiHSM specific attestation values.
241///
242/// See [Core Concepts: Attestation](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#attestation) for more details.
243#[non_exhaustive]
244#[derive(Debug)]
245pub struct YubiHsmAttestation {
246    /// Firmware version.
247    pub firmware_version: (u8, u8, u8),
248
249    /// YubiHSM serial number.
250    pub serial_number: u32,
251
252    /// Key origin.
253    ///
254    /// See [Core Concepts: Origin](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#origin) for more details.
255    pub origin: u8,
256
257    /// Key domain.
258    ///
259    /// See [Core Concepts: Domains](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#domains) for more details.
260    pub domains: u16,
261
262    /// Key capability bits.
263    ///
264    /// See [Core Concepts: Capabilities](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#capabilities) for more details.
265    pub capabilities: [u8; 8],
266
267    /// Object ID for which the attestation has been issued.
268    ///
269    /// See [Core Concepts: Object ID](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#object-id) for more details.
270    pub object_id: u16,
271
272    /// Object label as a UTF-8 string.
273    ///
274    /// See [Core Concepts: Label](https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#label) for more details.
275    pub label: String,
276}
277
278#[cfg(test)]
279mod tests {
280    use std::fs::File;
281
282    use testresult::TestResult;
283
284    use super::*;
285
286    #[test]
287    fn test_sample_chain() -> TestResult {
288        let mut validator = Validator::default();
289        validator.add_from_pem(File::open("yubihsm2-attest-ca-crt-pem")?)?;
290        validator.add_from_pem(File::open("intermediate-pem")?)?;
291
292        let binding = std::fs::read("attestation-cert.cer")?;
293        validator.add_from_der(binding)?;
294
295        validator.add_from_pem(File::open("attestation-pem")?)?;
296
297        Ok(())
298    }
299
300    #[test]
301    fn test_broken_chain() -> TestResult {
302        let mut validator = Validator::default();
303        validator.add_from_pem(File::open("yubihsm2-attest-ca-crt-pem")?)?;
304        validator.add_from_pem(File::open("intermediate-pem")?)?;
305
306        // device attestation cert is missing so the next line should fail
307
308        let result = validator.add_from_pem(File::open("attestation-pem")?);
309        assert!(result.is_err());
310
311        Ok(())
312    }
313
314    #[test]
315    fn test_leaf_extensions() -> TestResult {
316        let mut validator = Validator::default();
317
318        validator.add_from_pem(File::open("attestation-pem")?)?;
319        // https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#certificate-extensions
320        let extensions = validator.leaf_extensions()?.into_inner();
321        assert_eq!(extensions["1.3.6.1.4.1.41482.4.1"], vec![4, 3, 2, 4, 1]);
322        assert_eq!(
323            extensions["1.3.6.1.4.1.41482.4.2"],
324            vec![2, 4, 2, 11, 217, 253]
325        );
326        assert_eq!(extensions["1.3.6.1.4.1.41482.4.3"], vec![3, 2, 0, 1]);
327        assert_eq!(extensions["1.3.6.1.4.1.41482.4.4"], vec![3, 3, 0, 0, 1]);
328        assert_eq!(
329            extensions["1.3.6.1.4.1.41482.4.5"],
330            vec![3, 9, 0, 0, 0, 0, 0, 0, 0, 1, 0]
331        );
332        assert_eq!(extensions["1.3.6.1.4.1.41482.4.6"], vec![2, 1, 3]);
333        assert_eq!(extensions["1.3.6.1.4.1.41482.4.9"], vec![12, 0]);
334
335        Ok(())
336    }
337
338    #[test]
339    fn test_yubihsm_attestation_extensions() -> TestResult {
340        let mut validator = Validator::default();
341
342        validator.add_from_pem(File::open("attestation-pem")?)?;
343
344        let attestation = validator.leaf_extensions()?.to_yubihsm_attestation()?;
345        assert_eq!(attestation.firmware_version, (2, 4, 1));
346        assert_eq!(attestation.serial_number, 34_331_133);
347        assert_eq!(attestation.origin, 0x01);
348        assert_eq!(attestation.domains, 0x00_01);
349        assert_eq!(attestation.capabilities, [0, 0, 0, 0, 0, 0, 1, 0]);
350        assert_eq!(attestation.object_id, 0x03);
351        assert_eq!(attestation.label, "");
352
353        Ok(())
354    }
355
356    #[test]
357    fn test_yubihsm_attestation_extensions_fail() -> TestResult {
358        let mut validator = Validator::default();
359
360        validator.add_from_pem(File::open("intermediate-pem")?)?;
361
362        let attestation = validator.leaf_extensions()?.to_yubihsm_attestation();
363
364        let Err(e) = attestation else {
365            panic!("Parsing succeeded but should fail");
366        };
367
368        let Error::OidMissing { oid } = e else {
369            panic!("The error should indicate OidMissing but was: {e:?}");
370        };
371
372        assert_eq!(oid, "1.3.6.1.4.1.41482.4.1");
373
374        Ok(())
375    }
376}