diff --git a/Cargo.lock b/Cargo.lock index a33a1ad..2f1f6d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,8 +1605,10 @@ dependencies = [ "anyhow", "async-trait", "attest-data", + "attest-mock", "cfg-if", "const-oid 0.9.6", + "der", "dice-verifier", "ed25519-dalek", "helios-rot", @@ -1616,6 +1618,9 @@ dependencies = [ "pki-playground", "rats-corim", "rsa", + "serde", + "serde_with", + "sha2 0.10.9", "sha3", "thiserror 2.0.18", "tokio", @@ -4851,7 +4856,7 @@ dependencies = [ [[package]] name = "rats-corim" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/rats-corim#bb4a08dd507514f98c54f5fc67eadf14a0705f4e" +source = "git+https://github.com/oxidecomputer/rats-corim?rev=1493d7f58e189125641d81f03f2dfb5941567c82#1493d7f58e189125641d81f03f2dfb5941567c82" dependencies = [ "ciborium", "ciborium-io", @@ -7627,7 +7632,7 @@ dependencies = [ "ron 0.8.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.10.9", "yubihsm", ] diff --git a/Cargo.toml b/Cargo.toml index 9ec270a..4301c87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ der = { version = "0.7.10", default-features = false } ecdsa = { version = "0.16", default-features = false } ed25519-dalek = { version = "2.1", default-features = false } env_logger = { version = "0.11.11", default-features = false } +flagset = "0.4.7" getrandom = "0.4.3" hex.version = "0.4" hubpack = "0.1" @@ -43,7 +44,7 @@ log = { version = "0.4.33", features = ["std"] } p384 = { version = "0.13.1", default-features = false } pem-rfc7468 = { version = "1.0.0", default-features = false } pki-playground = { git = "https://github.com/oxidecomputer/pki-playground", rev = "98865b0f4716c52adcb6d940fdb8c3ab90ad409a" } -rats-corim.git = "https://github.com/oxidecomputer/rats-corim" +rats-corim = { git = "https://github.com/oxidecomputer/rats-corim", rev = "1493d7f58e189125641d81f03f2dfb5941567c82" } ron = "0.8" rpassword = "7.5.4" rsa = "0.9.8" @@ -53,7 +54,7 @@ serde-big-array = "0.5.1" serde_json = { version = "1.0.150", features = ["std", "alloc"] } serde_with = { version = "3.21.0", default-features = false } serialport = { git = "https://github.com/jgallagher/serialport-rs", branch = "illumos-support" } -sha2 = "0.11.0" +sha2 = "0.10.9" sha3 = { version = "0.10.8", default-features = false } sled-agent-client = { git = "https://github.com/oxidecomputer/omicron", rev = "f8e9052b768e1fa9c3fc16ef033fcab9ede3b806" } sled-agent-types-versions = { git = "https://github.com/oxidecomputer/omicron", rev = "f8e9052b768e1fa9c3fc16ef033fcab9ede3b806" } diff --git a/verifier/Cargo.toml b/verifier/Cargo.toml index a71373e..0ed8a63 100644 --- a/verifier/Cargo.toml +++ b/verifier/Cargo.toml @@ -9,6 +9,7 @@ license = "MPL-2.0" attest-data = { path = "../attest-data", features = ["std"] } async-trait.workspace = true const-oid.workspace = true +der.workspace = true ed25519-dalek = { workspace = true, features = ["std"] } helios-rot.path = "../helios-rot" hex = { workspace = true } @@ -16,12 +17,16 @@ hubpack.workspace = true p384 = { workspace = true, default-features = true } rats-corim.workspace = true rsa = { workspace = true, features = ["sha2"] } +serde = { workspace = true, features = ["derive"] } +serde_with = { workspace = true, features = ["macros"] } +sha2.workspace = true sha3.workspace = true thiserror.workspace = true x509-cert = { workspace = true, default-features = true } [build-dependencies] anyhow.workspace = true +attest-mock = { path = "../attest-mock", optional = true } cfg-if.workspace = true pki-playground = { workspace = true, optional = true } @@ -31,4 +36,4 @@ dice-verifier = { path = "../verifier", features = ["unittest"] } tokio = { workspace = true, features = ["macros", "rt"] } [features] -unittest = ["pki-playground"] +unittest = ["attest-mock", "pki-playground"] diff --git a/verifier/build.rs b/verifier/build.rs index 7dcfc72..3c5ddce 100644 --- a/verifier/build.rs +++ b/verifier/build.rs @@ -6,13 +6,30 @@ use anyhow::Result; cfg_if::cfg_if! { if #[cfg(feature = "unittest")] { use anyhow::{anyhow, Context}; - use pki_playground::{config, OutputFileExistsBehavior}; use std::{env, path::PathBuf}; } } +#[cfg(feature = "unittest")] +fn mock_data() -> Result<()> { + use attest_mock::{MockCorim, MockData}; + + // output directory where we put generated test inputs + let corim = MockCorim::load("test-corim.kdl")?; + let corim = corim.to_bytes()?; + let mut out = + PathBuf::from(env::var("OUT_DIR").context("Failed to get OUT_DIR")?); + out.push("test-corim.cbor"); + + Ok(std::fs::write(&out, &corim).with_context(|| { + format!("write mock measurement log to file: {}", out.display()) + })?) +} + #[cfg(feature = "unittest")] fn pki_setup() -> Result<()> { + use pki_playground::{config, OutputFileExistsBehavior}; + // output directory where we put generated test inputs let out = PathBuf::from(env::var("OUT_DIR").context("Failed to get OUT_DIR")?); @@ -36,6 +53,9 @@ fn pki_setup() -> Result<()> { } fn main() -> Result<()> { + #[cfg(feature = "unittest")] + mock_data()?; + #[cfg(feature = "unittest")] pki_setup()?; diff --git a/verifier/src/helios_rot.rs b/verifier/src/helios_rot.rs index 2b527ec..8c44e4f 100644 --- a/verifier/src/helios_rot.rs +++ b/verifier/src/helios_rot.rs @@ -2,9 +2,29 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use der::{ + self, + asn1::{BitString, Int, OctetString}, + Sequence, +}; pub use helios_rot::{Attestation, Nonce, Nonce48}; +pub use rats_corim::{Corim, Digest}; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use sha2::{ + digest::{ + const_oid::{AssociatedOid, ObjectIdentifier}, + typenum::Unsigned, + OutputSizeUser, + }, + Sha384, +}; +use std::{collections::HashMap, fmt}; use thiserror::Error; -use x509_cert::Certificate; +use x509_cert::{ + der::{Decode, DecodeValue, Header, SliceReader}, + Certificate, PkiPath, +}; #[derive(Debug, Error)] pub enum VerifyAttestationError { @@ -17,13 +37,6 @@ pub enum VerifyAttestationError { } // An attestation from the helios rot is: attestation = sign_alias(nonce) -// We must: -// - get the alias public key from the `Certificate` -// - reconstitute the message signed by the alias key (the nonce) -// When illumos supports runtime measurements the nonce will be combined with -// the serialized representation of the log using a hash function. -// NOTE: verify this w/ luqman / luqman's code ... or just test it -// - verify the attestation / signature over the message pub fn verify_attestation( alias: &Certificate, attestation: &Attestation, @@ -47,9 +60,463 @@ pub fn verify_attestation( Ok(()) } +pub const DICE_TCB_INFO: ObjectIdentifier = + ObjectIdentifier::new_unwrap("2.23.133.5.4.1"); + +// DICE Attestation Architecture §6.1.1: +// FWID ::== SEQUENCE { +#[derive(Debug, Sequence)] +pub struct Fwid { + // hashAlg OBJECT IDENTIFIER, + hash_algorithm: ObjectIdentifier, + + // digest OCTET STRING + digest: OctetString, +} + +// DICE Attestation Architecture §6.1.1: +// FWIDLIST ::== SEQUENCE SIZE (1..MAX) OF FWID +#[derive(Debug, Sequence)] +pub struct FwidList { + fwids: Vec, +} + +// NOTE: This structure represents an x509 extension defined by the TCG. This +// particular version is the one used by the AMD DPE that underlies the Helios +// RoT. It comes from an early draft of the TCG spec and is not compatible +// with published versions of the spec. +#[derive(Debug, Sequence)] +pub struct DiceTcbInfo { + #[asn1(context_specific = "0", tag_mode = "EXPLICIT", optional = "true")] + vendor: Option, + + #[asn1(context_specific = "1", tag_mode = "EXPLICIT", optional = "true")] + model: Option, + + #[asn1(context_specific = "4", tag_mode = "EXPLICIT", optional = "true")] + layer: Option, + + #[asn1(context_specific = "5", tag_mode = "EXPLICIT", optional = "true")] + index: Option, + + #[asn1(context_specific = "6", tag_mode = "IMPLICIT", optional = "true")] + fwids: Option, + + #[asn1(context_specific = "7", tag_mode = "EXPLICIT", optional = "true")] + flags: Option, + + #[asn1(context_specific = "9", tag_mode = "EXPLICIT", optional = "true")] + r#type: Option, +} + +#[derive(Debug, Error)] +pub enum ArrayError { + #[error("Slice is the wrong length")] + TryFromSliceError(std::array::TryFromSliceError), +} + +/// Array is the type we use as a base for types that are constant sized byte +/// buffers. +#[serde_as] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct Array(#[serde_as(as = "[_; N]")] pub [u8; N]); + +impl Array { + pub const LENGTH: usize = N; +} + +impl fmt::Display for Array { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl Default for Array { + /// Create and initialize an `Array` to 0's. + fn default() -> Self { + Self([0u8; N]) + } +} + +impl From<[u8; N]> for Array { + /// Create an Array from the provided array. + fn from(item: [u8; N]) -> Self { + Self(item) + } +} + +impl TryFrom<&[u8]> for Array { + type Error = ArrayError; + + /// Attempt to create an `Array` from the slice provided. + fn try_from(item: &[u8]) -> Result { + let array: [u8; N] = + item.try_into().map_err(Self::Error::TryFromSliceError)?; + Ok(Array::(array)) + } +} + +impl TryFrom> for Array { + type Error = ArrayError; + + /// Attempt to create an `Array` from the `Vec` provided. + fn try_from(item: Vec) -> Result { + item[..].try_into() + } +} + +impl AsRef<[u8]> for Array { + fn as_ref(&self) -> &[u8] { + &self.0[..] + } +} + +const SHA384_DIGEST_SIZE: usize = ::OutputSize::USIZE; +pub type Sha384Digest = Array; + +#[derive(Debug, Error)] +pub enum MeasurementError { + #[error("Deserialization failed")] + Deserialize, + #[error("Bad size for measurement: {size}")] + BadSize { size: usize, source: ArrayError }, + #[error("Fwid provided contains unsupported digest value")] + UnsupportedDigest, + #[error("CoRIM Digest contained tagged value")] + TaggedDigest, +} + +/// Measurement is an enum that can hold any of the hash algorithms that we support +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub enum Measurement { + Sha384(Sha384Digest), +} + +impl Default for Measurement { + fn default() -> Self { + Measurement::Sha384(Sha384Digest::default()) + } +} + +impl fmt::Display for Measurement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Sha384(digest) => { + write!(f, "sha-384;{digest}") + } + } + } +} + +impl TryFrom<&Fwid> for Measurement { + type Error = MeasurementError; + + /// Attempt to create an `Array` from the slice provided. + fn try_from(fwid: &Fwid) -> Result { + // map from fwid.hash_algorithm ObjectIdentifier to Measurement enum + if fwid.hash_algorithm == Sha384::OID { + // pull the associated data from fwid.digest OctetString + let digest = fwid.digest.as_bytes(); + let digest = Sha384Digest::try_from(digest).map_err(|e| { + Self::Error::BadSize { + size: digest.len(), + source: e, + } + })?; + + Ok(Measurement::Sha384(digest)) + } else { + Err(Self::Error::UnsupportedDigest) + } + } +} + +impl TryFrom for Measurement { + type Error = MeasurementError; + + /// Attempt to create a Measurement from the `rats_corim::Digest` provided. + fn try_from(digest: rats_corim::Digest) -> Result { + match digest.alg { + 7 => { + let bytes = match &digest.val { + rats_corim::TaggedBytes::Bytes(v) => v, + rats_corim::TaggedBytes::Tagged(_, _) => { + return Err(Self::Error::TaggedDigest) + } + }; + Ok(Measurement::Sha384(bytes[..].try_into().map_err(|e| { + MeasurementError::BadSize { + size: bytes.len(), + source: e, + } + })?)) + } + _ => Err(Self::Error::UnsupportedDigest), + } + } +} + +/// This is a collection to represent the measurements received from an +/// attestor. These measurements will come from the measurement log and the +/// DiceTcbInfo extension(s) in the attestation cert chain / pki path. +#[derive(Debug, PartialEq)] +pub struct MeasurementList(Vec); + +/// Possible errors produced by the `MeasurmentSet` construction process. +#[derive(Debug, Error)] +pub enum MeasurementListError { + #[error("failed to create reader from extension value")] + ExtensionDecode(#[source] der::Error), + #[error("failed to decode extension header")] + HeaderDecode(#[source] der::Error), + #[error("failed to decode TcbInfo extension")] + DiceTcbInfoDecode(#[source] der::Error), + #[error("failed to create Measurement from DiceTcbInfo extension")] + MeasurementConstruct(#[from] MeasurementError), +} + +/// Construct a MeasurementList from the provided artifacts. The +/// trustworthiness of these artifacts must be established independently +/// (see `verify_cert_chain` and `verify_attestation`). +impl MeasurementList { + /// Construct a MeasurementList from the provided artifacts. The + /// trustworthiness of these artifacts must be established independently + /// (see `verify_cert_chain` and `verify_attestation`). + pub fn from_artifacts( + pki_path: &PkiPath, + ) -> Result { + let mut measurements = Vec::new(); + + for cert in pki_path { + if let Some(extensions) = &cert.tbs_certificate.extensions { + for ext in extensions { + if ext.extn_id == DICE_TCB_INFO { + let mut reader = + SliceReader::new(ext.extn_value.as_bytes()) + .map_err( + MeasurementListError::ExtensionDecode, + )?; + let header = Header::decode(&mut reader) + .map_err(MeasurementListError::HeaderDecode)?; + + let tcb_info = + DiceTcbInfo::decode_value(&mut reader, header) + .map_err( + MeasurementListError::DiceTcbInfoDecode, + )?; + if let Some(fwid_vec) = &tcb_info.fwids { + for fwid in &fwid_vec.fwids { + let measurement = Measurement::try_from(fwid)?; + measurements.push(measurement); + } + } + } + } + } + } + + Ok(Self(measurements)) + } +} + +impl<'a> std::iter::IntoIterator for &'a MeasurementList { + type Item = &'a Measurement; + type IntoIter = std::slice::Iter<'a, Measurement>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl std::fmt::Display for MeasurementList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "measurement list")?; + for m in &self.0 { + writeln!(f, " {}", m)?; + } + if self.0.is_empty() { + writeln!(f, "(set is empty)")?; + } + Ok(()) + } +} + +// collection that maps a `Measurement` to the CoRIM `mkey` / string +// identifier assigned to this digest +pub struct ReferenceMeasurementMap(HashMap); + +#[derive(Debug, Error)] +pub enum ReferenceMeasurementMapError { + #[error("Digest is not the expected length")] + BadDigest(#[from] MeasurementError), + #[error("No such measurement found in ReferenceMeasurementMap: {0}")] + NotFound(Measurement), + #[error("CoRIM measurement map has no values")] + NoDigest, + // we currently assume that there is a 1:1 correspondence between each + // measurement key and value + #[error("CoRIM measurement map has multiple digests")] + MultipleDigests, + #[error("CoRIM measurement map mkey is not Text")] + KeyNotText, + #[error("CoRIM measurement map has no mkey")] + NoKey, +} + +impl TryFrom<&[Corim]> for ReferenceMeasurementMap { + type Error = ReferenceMeasurementMapError; + + fn try_from(corims: &[Corim]) -> Result { + use rats_corim::TypeChoice; + + let mut set = HashMap::new(); + + // iterate such that we get the label (whatever it's called in CoRIM + // speak) + for corim in corims { + let comid = corim.tags.wrapped.clone().into_iter(); + let reference_triple = + comid.flat_map(|x| x.triples.reference_triple.into_iter()); + let reference_triple = + reference_triple.flat_map(|x| x.wrapped.into_iter()); + let measurement_maps = + reference_triple.flat_map(|x| x.ref_claims.into_iter()); + + for measurement_map in measurement_maps { + let measurement_key = measurement_map.mkey; + let mkey = if let Some(t) = measurement_key { + match t { + TypeChoice::Text(s) => s, + _ => return Err(Self::Error::KeyNotText), + } + } else { + return Err(Self::Error::NoKey); + }; + + let measurement_values = measurement_map.mval; + if let Some(d) = measurement_values.digests { + let digests: Vec = d + .into_iter() + .flat_map(|x| x.wrapped.into_iter()) + .collect(); + if digests.is_empty() { + return Err(Self::Error::NoDigest); + } else if digests.len() > 1 { + return Err(Self::Error::MultipleDigests); + } + for digest in digests { + set.insert(digest.try_into()?, mkey.clone()); + } + } else { + return Err(Self::Error::NoDigest); + }; + } + } + + Ok(Self(set)) + } +} + +impl std::fmt::Display for ReferenceMeasurementMap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Reference measurements")?; + for (key, val) in &self.0 { + writeln!(f, " label: {val}, measurement: {key}")?; + } + if self.0.is_empty() { + writeln!(f, "(ReferenceMeasurementMap is empty)")?; + } + Ok(()) + } +} + +impl ReferenceMeasurementMap { + pub fn get_id(&self, m: &Measurement) -> Option { + //TODO: do this w/o all of the clones + self.0.get(m).cloned() + } +} + +/// Possible errors produced by the measurement verification / appraisal +/// process. +#[derive(Debug, Error)] +pub enum VerifyMeasurementsError { + #[error("Measurements are not a subset of reference measurements: {0}")] + NotSubset(MeasurementList), + #[error("Policy not satisfied")] + PolicyNotSatisfied, +} + +// This is the FnPolicy that most closely matches what we expect to enforce +// initially. This policy is effectively: +// the measurements from the leaf and last intermediate (the first and second +// measurement in the MeasurementList) must correspond to reference +// measurements with the mkeys "phase2" & "hbs" respectively +#[cfg(feature = "unittest")] +fn test_policy( + measurements: &MeasurementList, + corpus: &ReferenceMeasurementMap, +) -> bool { + let mut labeled_measurements = Vec::new(); + + // for each measurement in the MeasurementList + for (i, m) in measurements.into_iter().enumerate() { + // if it's in the corpus, we: + // - get the mkey string + if let Some(l) = corpus.get_id(m) { + // store the mkey & index of the measurement in the MeasurementList + labeled_measurements.push((l, i)); + } + } + + // The list of (mkey, index) tuples that we require the attested + // measurements to match. + let required_measurements = + vec![("phase2".to_string(), 0), ("hbs".to_string(), 1)]; + + if labeled_measurements == required_measurements { + true + } else { + false + } +} + +// our default `FnPolicy` that will always fail till we have mkeys tracked in +// OANA: https://github.com/oxidecomputer/oana/issues/44 +fn fail_policy( + _measurements: &MeasurementList, + _corpus: &ReferenceMeasurementMap, +) -> bool { + false +} + +// our measurement appraisal policy is a function with this signature +type FnPolicy = fn(&MeasurementList, &ReferenceMeasurementMap) -> bool; + +#[cfg(feature = "unittest")] +const POLICY: FnPolicy = test_policy; +#[cfg(not(feature = "unittest"))] +const POLICY: FnPolicy = fail_policy; + +/// This function implements the core of our attestation appraisal policy. +/// The trustworthiness of the parameters provided must be established +/// independently. +pub fn verify_measurements( + measurements: &MeasurementList, + corpus: &ReferenceMeasurementMap, +) -> Result<(), VerifyMeasurementsError> { + if POLICY(measurements, corpus) { + Ok(()) + } else { + Err(VerifyMeasurementsError::PolicyNotSatisfied) + } +} + #[cfg(test)] mod test { - use crate::helios_rot::{self, Nonce}; + use crate::helios_rot::{ + self, MeasurementList, Nonce, ReferenceMeasurementMap, + }; use ::helios_rot::{HeliosRot, HeliosRotMock}; use std::{ env, fs, @@ -142,4 +609,37 @@ mod test { Err(_) => assert!(false), } } + + #[test] + fn appraise_measurements() { + use rats_corim::Corim; + use std::{fs, slice}; + + // load alias cert chain + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + + let cert_chain = out.join("helios-rot.certlist.pem"); + let cert_chain = fs::read_to_string(&cert_chain) + .expect("read cert chain pem from file"); + let cert_chain = Certificate::load_pem_chain(cert_chain.as_ref()) + .expect("certificate chain from pem"); + + // create `MeasurementList` from cert chain + let measurements = MeasurementList::from_artifacts(&cert_chain) + .expect("measurement set from cert chain"); + println!("MeasurementList: {measurements}"); + + // load the corims + let corim = out.join("test-corim.cbor"); + let corim = Corim::from_file(&corim).expect("load corim from file"); + + // create `ReferenceMeasurementMap + let reference_measurements = + ReferenceMeasurementMap::try_from(slice::from_ref(&corim)) + .expect("ReferenceMeasurementMap from CoRIMs"); + println!("ReferenceMeasurementMap: {reference_measurements}"); + + helios_rot::verify_measurements(&measurements, &reference_measurements) + .expect("Verify measurement set against reference measurements"); + } } diff --git a/verifier/test-corim.kdl b/verifier/test-corim.kdl new file mode 100644 index 0000000..fcb2ca5 --- /dev/null +++ b/verifier/test-corim.kdl @@ -0,0 +1,21 @@ +// This KDL describes a rats_corim::Corim instance from +// https://github.com/oxidecomputer/dice-util. Use the `attest-mock` tool to +// produce a CBOR encoding of it. +// ```shell +// $ attest-mock this-file.kdl corim +// ``` +vendor "test" +tag-id "helios-tag-id" +id "helios-id" + +measurement { + mkey "hbs" + algorithm 7 + digest "2BC703A0D10FC45D31ED776A6D36A5EAC428329A4A69FD44FF99C5337F37A559668E5CDB0DEDB7C05C12D6E639161063" +} + +measurement { + mkey "phase2" + algorithm 7 + digest "ABF643ADBA6635FF09D795E7D9962EE732ED8FE956F7742E28CF38CECC0AFFB5C6D092A91D8E6C45AC5309DBCA1235F6" +}