diff --git a/Cargo.toml b/Cargo.toml index d835130..3adfc3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,14 @@ igvm = { path = "igvm", version = "0.4.0" } anyhow = "1.0" bitfield-struct = "0.12" +corim = "0.1.2" crc32fast = { version = "1.3.2", default-features = false } hex = { version = "0.4", default-features = false } open-enum = "0.5.2" range_map_vec = "0.2.0" +sha2 = "0.10" static_assertions = "1.1" thiserror = "2.0" tracing = "0.1" +uuid = { version = "1", features = ["v5"] } zerocopy = { version = "0.8.14", features = ["derive"] } diff --git a/igvm/Cargo.toml b/igvm/Cargo.toml index 748113c..3d9cac9 100644 --- a/igvm/Cargo.toml +++ b/igvm/Cargo.toml @@ -40,12 +40,15 @@ crate-type = ["staticlib", "rlib"] igvm_defs = { workspace = true, features = ["unstable"] } bitfield-struct.workspace = true +corim = { workspace = true, optional = true } range_map_vec.workspace = true crc32fast.workspace = true hex = { workspace = true, features = ["alloc"] } open-enum.workspace = true +sha2 = { workspace = true, optional = true } thiserror.workspace = true tracing.workspace = true +uuid = { workspace = true, optional = true } zerocopy = { workspace = true, features = ["alloc"] } static_assertions.workspace = true @@ -53,4 +56,4 @@ static_assertions.workspace = true default = [] capi = ["igvm-c"] igvm-c = [] # Add exports that allow the library to be used from C -corim = ["igvm_defs/corim"] +corim = ["igvm_defs/corim", "dep:corim", "dep:uuid", "dep:sha2"] # CoRIM launch measurement support diff --git a/igvm/src/c_api.rs b/igvm/src/c_api.rs index e96f6a6..29d0d81 100644 --- a/igvm/src/c_api.rs +++ b/igvm/src/c_api.rs @@ -61,6 +61,10 @@ pub enum IgvmResult { IGVMAPI_INVALID_FIXED_HEADER_ARCH = -26, IGVMAPI_MERGE_REVISION = -27, IGVMAPI_INVALID_CCA_POLICY_COMPATIBILITY_MASK = -28, + #[cfg(feature = "corim")] + IGVMAPI_CORIM_GENERATION = -29, + #[cfg(feature = "corim")] + IGVMAPI_MEASUREMENT_FAILED = -30, } type IgvmHandle = i32; @@ -168,6 +172,10 @@ fn translate_error(error: Error) -> IgvmResult { Error::InvalidCcaPolicyCompatibilityMask(_) => { IgvmResult::IGVMAPI_INVALID_CCA_POLICY_COMPATIBILITY_MASK } + #[cfg(feature = "corim")] + Error::CorimGeneration(_) => IgvmResult::IGVMAPI_CORIM_GENERATION, + #[cfg(feature = "corim")] + Error::MeasurementFailed(_) => IgvmResult::IGVMAPI_MEASUREMENT_FAILED, } } diff --git a/igvm/src/corim/launch_measurement/builder.rs b/igvm/src/corim/launch_measurement/builder.rs new file mode 100644 index 0000000..710a8ee --- /dev/null +++ b/igvm/src/corim/launch_measurement/builder.rs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! CoRIM launch-measurement builder. + +use corim::builder::ComidBuilder; +use corim::builder::CorimBuilder; +use corim::types::common::MeasuredElement; +use corim::types::common::TagIdChoice; +use corim::types::corim::CorimId; +use corim::types::corim::ProfileChoice; +use corim::types::environment::ClassMap; +use corim::types::environment::EnvironmentMap; +use corim::types::measurement::Digest; +use corim::types::measurement::MeasurementMap; +use corim::types::measurement::MeasurementValuesMap; +use corim::types::measurement::SvnChoice; +use corim::types::triples::ConditionalSeriesRecord; +use igvm_defs::IgvmPlatformType; +use uuid::Uuid; + +use super::platform_info; +use super::profile::PROFILE_URI; +use super::Error; +use super::TAG_ID_NAMESPACE; + +// `ResolvedMeasurement` is intentionally profile-specific. When a second +// profile arrives, common shape -- e.g., `(mkey, digest_alg, digest)` -- +// can be promoted to `crate::corim` if duplication justifies it. + +/// A measurement resolved by the serializer, ready for CBOR encoding. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedMeasurement { + pub mkey: String, + pub digest_alg: i64, + pub digest: Vec, +} + +/// Build a complete CoRIM launch measurement endorsement as tag-501-wrapped CBOR bytes. +/// +/// Emits a single reference-values triple containing the measurement, +/// plus a single CES triple that selects on that measurement and +/// endorses `svn`. +pub(crate) fn build_corim_bytes( + platform: IgvmPlatformType, + measurement: &ResolvedMeasurement, + svn: u64, +) -> Result, Error> { + let info = platform_info(platform).expect("platform validated by LaunchMeasurement"); + + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some(info.vendor.into()), + model: Some(info.model.into()), + layer: None, + index: None, + }), + instance: None, + group: None, + }; + + let tag_id = Uuid::new_v5( + &TAG_ID_NAMESPACE, + format!("{}/{}", info.vendor, info.model).as_bytes(), + ) + .to_string(); + + let ref_meas = build_measurement_map(measurement); + + // CES selection: same measurement-map as the reference value. + let ces_selection = ref_meas.clone(); + + let ces_addition = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(svn)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + // Declare the platform env once in the builder's catalog so the + // reference triple and the CES condition share it by ref. The + // `_for` builder methods resolve the ref to one inline env at + // `build()` time, and `strict_links(true)` promotes any drift + // between catalog-anchored envs into a build-time error. + let mut comid_builder = ComidBuilder::new(TagIdChoice::Text(tag_id)); + let env_ref = comid_builder + .declare_env("platform", env) + .map_err(|e| Error::Build(Box::new(e)))?; + + let comid = comid_builder + .add_reference_triple_for(&env_ref, vec![ref_meas]) + .add_conditional_endorsement_series_for( + &env_ref, + Vec::new(), + None, + vec![ConditionalSeriesRecord::new( + vec![ces_selection], + vec![ces_addition], + )], + ) + .strict_links(true) + .build() + .map_err(|e| Error::Build(Box::new(e)))?; + + // Build CoRIM with profile URI + let corim_id = format!("{}/{}/launch-measurement", info.vendor, info.model); + CorimBuilder::new(CorimId::Text(corim_id)) + .set_profile(ProfileChoice::Uri(PROFILE_URI.into())) + .add_comid_tag(comid) + .map_err(|e| Error::Build(Box::new(e)))? + .build_bytes() + .map_err(|e| Error::Build(Box::new(e))) +} + +fn build_measurement_map(m: &ResolvedMeasurement) -> MeasurementMap { + MeasurementMap { + mkey: Some(MeasuredElement::Text(m.mkey.clone())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(m.digest_alg, m.digest.clone())]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + } +} + +#[cfg(test)] +mod tests { + use igvm_defs::IgvmPlatformType; + + use crate::corim::launch_measurement::Error; + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + + fn build_and_decode( + le: LaunchMeasurement, + ) -> ( + corim::types::corim::CorimMap, + Vec, + ) { + // Tests use a fixed digest in place of the IGVM file's + // auto-computed launch measurement. + let platform = le.platform(); + let kind = *le.measurement_kinds().iter().next().unwrap(); + let (mkey, alg, len) = super::super::measurement_info(platform, kind).unwrap(); + let measurement = super::ResolvedMeasurement { + mkey: mkey.to_string(), + digest_alg: alg, + digest: vec![0xAA; len], + }; + + let svn = le.triples()[0].svn(); + + let bytes = super::build_corim_bytes(platform, &measurement, svn).unwrap(); + corim::validate::decode_and_validate(&bytes).unwrap() + } + + #[test] + fn amd_sev_snp_round_trip() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + le.endorse(1) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + + let (corim, comids) = build_and_decode(le); + assert_eq!(corim.id.to_string(), "AMD/SEV-SNP/launch-measurement"); + assert_eq!(comids.len(), 1); + let tag_id = comids[0].tag_identity.tag_id.to_string(); + assert_eq!(tag_id, "77e8061e-4634-5e53-a848-d1d09e996843"); + } + + #[test] + fn intel_tdx_round_trip() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::TDX).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + le.endorse(5) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + + let (corim, _) = build_and_decode(le); + assert_eq!(corim.id.to_string(), "Intel/TDX/launch-measurement"); + } + + #[test] + fn microsoft_vbs_round_trip() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::VSM_ISOLATION).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + le.endorse(2) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + + let (corim, _) = build_and_decode(le); + assert_eq!(corim.id.to_string(), "Microsoft/VBS/launch-measurement"); + } + + #[test] + fn unsupported_platform_rejected() { + let err = LaunchMeasurement::for_platform(IgvmPlatformType::NATIVE).unwrap_err(); + assert!( + matches!(err, Error::UnsupportedPlatform(IgvmPlatformType::NATIVE)), + "got: {err:?}" + ); + } + + #[test] + fn select_unpopulated_kind_rejected() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + let err = le.endorse(1).with(MeasurementKind::Launch).unwrap_err(); + assert!(matches!(err, Error::MeasurementNotPopulated { .. })); + } + + #[test] + fn duplicate_selection_rejected() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + let err = le + .endorse(1) + .with(MeasurementKind::Launch) + .unwrap() + .with(MeasurementKind::Launch) + .unwrap_err(); + assert!(matches!(err, Error::DuplicateSelection { .. })); + } + + #[test] + fn empty_selection_rejected() { + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + let err = le.endorse(1).finish().unwrap_err(); + assert!(matches!(err, Error::EmptySelection)); + } +} diff --git a/igvm/src/corim/launch_measurement/mod.rs b/igvm/src/corim/launch_measurement/mod.rs new file mode 100644 index 0000000..9f00e99 --- /dev/null +++ b/igvm/src/corim/launch_measurement/mod.rs @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! Launch measurement CoRIM profile. +//! +//! This module implements the IGVM launch measurement CoRIM profile +//! (`tag:microsoft.com,2026:launch-measurement/v1`), which produces +//! CoRIM documents containing a launch measurement reference value and +//! an SVN endorsement for supported CVM platforms. +//! +//! # Two-stage builder +//! +//! The user-facing API is a two-stage builder: +//! +//! 1. **Stage 1 -- populate measurements.** Construct a [`LaunchMeasurement`] +//! via [`LaunchMeasurement::for_platform`], then mark each profile-defined +//! measurement as populated via +//! [`set_measurement`](LaunchMeasurement::set_measurement). The digest +//! bytes are taken from the IGVM file's auto-computed launch measurement +//! at serialization time. Every populated measurement becomes a +//! reference-value in the CoRIM. +//! +//! 2. **Stage 2 -- define endorsement policy.** Call +//! [`endorse`](LaunchMeasurement::endorse) to start a [`CesTripleBuilder`] +//! for a given SVN, then select which populated measurements participate +//! in the CES triple via [`with`](CesTripleBuilder::with). Finalize with +//! [`finish`](CesTripleBuilder::finish) to obtain a [`CesTriple`]. +//! +//! Finally, call [`build`](LaunchMeasurement::build) to consume the +//! endorsement and produce a [`CorimTemplate`](crate::CorimTemplate) ready +//! for [`IgvmSerializer::add_corim`](crate::IgvmSerializer::add_corim). +//! +//! # Future: caller-supplied digests +//! +//! When future CoRIM profiles need caller-supplied digest bytes (e.g., for +//! TDX RTMRs or runtime-extended measurements), a `DigestSource`-like type +//! should be introduced at the [`crate::corim`] module level so it can be +//! shared across profiles, rather than re-introduced here. + +pub(crate) mod builder; +pub mod profile; + +use std::collections::HashSet; + +pub use igvm_defs::IgvmPlatformType; + +use crate::CorimTemplate; + +/// Fixed namespace UUID for deterministic CoMID tag-id derivation. +/// +/// `tag-id = UUIDv5(TAG_ID_NAMESPACE, "{vendor}/{model}")` +pub const TAG_ID_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([ + 0x85, 0xf3, 0xf1, 0xc2, 0x22, 0xa8, 0x44, 0x1e, 0xa1, 0xb9, 0xbc, 0xcf, 0xb6, 0x3e, 0xd5, 0xf7, +]); + +// -- Profile catalog ---------------------------------------------------- + +/// Internal record describing a platform's profile-defined measurement layout. +pub(crate) struct PlatformInfo { + pub vendor: &'static str, + pub model: &'static str, + pub mkey: &'static str, + pub digest_alg: i64, + pub digest_len: usize, +} + +/// Named Information Hash Algorithm ID for SHA-256 (RFC 6920). +const NI_SHA256: i64 = 1; +/// Named Information Hash Algorithm ID for SHA-384 (RFC 6920). +const NI_SHA384: i64 = 7; + +/// Canonical list of supported platforms. +pub(crate) fn known_platforms() -> &'static [PlatformInfo] { + &[ + PlatformInfo { + vendor: "Intel", + model: "TDX", + mkey: "MRTD", + digest_alg: NI_SHA384, + digest_len: 48, + }, + PlatformInfo { + vendor: "AMD", + model: "SEV-SNP", + mkey: "MEASUREMENT", + digest_alg: NI_SHA384, + digest_len: 48, + }, + PlatformInfo { + vendor: "Microsoft", + model: "VBS", + mkey: "MEASUREMENT", + digest_alg: NI_SHA256, + digest_len: 32, + }, + ] +} + +fn platform_info(platform: IgvmPlatformType) -> Option<&'static PlatformInfo> { + let (vendor, model) = match platform { + IgvmPlatformType::TDX => ("Intel", "TDX"), + IgvmPlatformType::SEV_SNP => ("AMD", "SEV-SNP"), + IgvmPlatformType::VSM_ISOLATION => ("Microsoft", "VBS"), + _ => return None, + }; + known_platforms() + .iter() + .find(|p| p.vendor == vendor && p.model == model) +} + +/// Look up the `(mkey, digest_alg, digest_len)` tuple for a profile-defined +/// measurement on the given platform. +/// +/// Returns `None` if the platform is not supported by this profile, or if +/// the measurement kind has no mapping for that platform. +pub fn measurement_info( + platform: IgvmPlatformType, + kind: MeasurementKind, +) -> Option<(&'static str, i64, usize)> { + let info = platform_info(platform)?; + match kind { + MeasurementKind::Launch => Some((info.mkey, info.digest_alg, info.digest_len)), + } +} + +// -- Public types ------------------------------------------------------- + +/// Identifies a profile-defined measurement. +/// +/// The exact CBOR `mkey` text and hash algorithm are determined by the +/// profile and the target platform. Today only [`Self::Launch`] is +/// supported; future variants (e.g., `Rtmr(u8)` for TDX) can be added +/// without breaking existing callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum MeasurementKind { + /// The platform's primary launch measurement. + /// + /// - **TDX**: MRTD (SHA-384) + /// - **SEV-SNP**: launch digest (SHA-384) + /// - **VBS**: boot measurement digest (SHA-256) + Launch, +} + +/// Errors from launch measurement CoRIM generation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The platform type is not supported for CoRIM launch measurements. + #[error( + "unsupported platform type {0:?}: only SEV_SNP, TDX, and \ + VSM_ISOLATION are supported for CoRIM launch measurements" + )] + UnsupportedPlatform(IgvmPlatformType), + /// The measurement kind is not defined for this platform. + #[error("measurement {kind:?} is not defined for platform {platform:?}")] + UnsupportedMeasurement { + /// The platform that was queried. + platform: IgvmPlatformType, + /// The measurement kind that was rejected. + kind: MeasurementKind, + }, + /// A CES triple referenced a measurement that was not populated in + /// stage 1 (via [`LaunchMeasurement::set_measurement`]). + #[error( + "measurement {kind:?} is not populated; call \ + LaunchMeasurement::set_measurement first" + )] + MeasurementNotPopulated { + /// The kind that was referenced but not populated. + kind: MeasurementKind, + }, + /// The same measurement kind was selected twice in a single CES triple. + #[error("measurement {kind:?} already selected in this CES triple")] + DuplicateSelection { + /// The duplicated kind. + kind: MeasurementKind, + }, + /// A CES triple was finalized with an empty selection. + #[error("CES triple must select at least one measurement")] + EmptySelection, + /// CoRIM building or encoding failed. + #[error("CoRIM build failed")] + Build(#[source] Box), +} + +// -- Stage 1: LaunchMeasurement ----------------------------------------- + +/// Profile-driven endorsement under construction. +/// +/// Constructed via [`for_platform`](Self::for_platform). Populate +/// measurements with [`set_measurement`](Self::set_measurement), then +/// build CES triples via [`endorse`](Self::endorse). Finalize with +/// [`build`](Self::build) to produce a [`CorimTemplate`]. +#[derive(Debug, Clone)] +pub struct LaunchMeasurement { + platform: IgvmPlatformType, + measurements: HashSet, + triples: Vec, +} + +impl LaunchMeasurement { + /// Start a new launch measurement endorsement for the given platform. + /// + /// Returns [`Error::UnsupportedPlatform`] if the platform is not one + /// the profile supports. + pub fn for_platform(platform: IgvmPlatformType) -> Result { + if platform_info(platform).is_none() { + return Err(Error::UnsupportedPlatform(platform)); + } + Ok(Self { + platform, + measurements: HashSet::new(), + triples: Vec::new(), + }) + } + + /// The platform this endorsement targets. + pub fn platform(&self) -> IgvmPlatformType { + self.platform + } + + /// Mark a profile-defined measurement as populated. + /// + /// Each call is idempotent for the same `kind`. All populated + /// measurements are emitted as reference-values in the final CoRIM + /// document, with digest bytes taken from the IGVM file's + /// auto-computed launch measurement at serialization time. + /// + /// # Errors + /// + /// Returns [`Error::UnsupportedMeasurement`] if `kind` is not defined + /// for the platform. + pub fn set_measurement(&mut self, kind: MeasurementKind) -> Result<&mut Self, Error> { + if measurement_info(self.platform, kind).is_none() { + return Err(Error::UnsupportedMeasurement { + platform: self.platform, + kind, + }); + } + self.measurements.insert(kind); + Ok(self) + } + + /// Returns the measurement kinds populated so far. + pub fn populated_measurements(&self) -> impl Iterator + '_ { + self.measurements.iter().copied() + } + + /// Returns `true` if `kind` has been populated. + pub fn is_populated(&self, kind: MeasurementKind) -> bool { + self.measurements.contains(&kind) + } + + /// Start a Stage-2 CES triple builder that endorses `svn` when its + /// selected measurements all match. + pub fn endorse(&mut self, svn: u64) -> CesTripleBuilder<'_> { + CesTripleBuilder { + endorsement: self, + svn, + selected: Vec::new(), + } + } + + /// Returns the CES triples accumulated so far. + pub fn triples(&self) -> &[CesTriple] { + &self.triples + } + + /// Consume this endorsement and wrap it in a [`CorimTemplate`] ready + /// for [`IgvmSerializer::add_corim`](crate::IgvmSerializer::add_corim). + pub fn build(self) -> CorimTemplate { + CorimTemplate::LaunchMeasurement(self) + } + + /// Private accessor used by the serializer to enumerate populated + /// measurement kinds. + pub(crate) fn measurement_kinds(&self) -> &HashSet { + &self.measurements + } +} + +// -- Stage 2: CesTripleBuilder ------------------------------------------ + +/// Builder for a single conditional-endorsement-series triple. +/// +/// Created by [`LaunchMeasurement::endorse`]. The lifetime ties the +/// builder to its parent endorsement so that [`with`](Self::with) can +/// validate selected measurements against the populated catalog. +#[derive(Debug)] +pub struct CesTripleBuilder<'a> { + endorsement: &'a mut LaunchMeasurement, + svn: u64, + selected: Vec, +} + +impl CesTripleBuilder<'_> { + /// Add a populated measurement to this CES triple's selection. + /// + /// # Errors + /// + /// - [`Error::MeasurementNotPopulated`] if `kind` was not populated + /// in stage 1 via [`LaunchMeasurement::set_measurement`]. + /// - [`Error::DuplicateSelection`] if `kind` is already in this + /// triple's selection. + pub fn with(mut self, kind: MeasurementKind) -> Result { + if !self.endorsement.measurements.contains(&kind) { + return Err(Error::MeasurementNotPopulated { kind }); + } + if self.selected.contains(&kind) { + return Err(Error::DuplicateSelection { kind }); + } + self.selected.push(kind); + Ok(self) + } + + /// Finalize this CES triple and append it to the parent endorsement. + /// + /// Returns [`Error::EmptySelection`] if no measurements were selected. + pub fn finish(self) -> Result<(), Error> { + if self.selected.is_empty() { + return Err(Error::EmptySelection); + } + self.endorsement.triples.push(CesTriple { + svn: self.svn, + selected: self.selected, + }); + Ok(()) + } +} + +// -- Finalized CES triple record ---------------------------------------- + +/// A finalized conditional-endorsement-series triple. +/// +/// Produced by [`CesTripleBuilder::finish`] and owned by its parent +/// [`LaunchMeasurement`]. +#[derive(Debug, Clone)] +pub struct CesTriple { + svn: u64, + selected: Vec, +} + +impl CesTriple { + /// The SVN this triple endorses. + pub fn svn(&self) -> u64 { + self.svn + } + + /// The measurement kinds selected for this CES triple, in the order + /// they were added. + pub fn selected_measurements(&self) -> &[MeasurementKind] { + &self.selected + } +} diff --git a/igvm/src/corim/launch_measurement/profile.rs b/igvm/src/corim/launch_measurement/profile.rs new file mode 100644 index 0000000..7d4ea8a --- /dev/null +++ b/igvm/src/corim/launch_measurement/profile.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! Launch Measurement CoRIM Profile. +//! +//! This module defines the CoRIM profile for launch measurements per +//! draft-ietf-rats-corim-10 Sec. 4.1.4. A profile constrains the base CoRIM +//! CDDL to a specific use case without changing the schema. +//! +//! # Profile URI +//! +//! ```text +//! tag:microsoft.com,2026:launch-measurement/v1 +//! ``` +//! +//! # Semantics +//! +//! A CoRIM conforming to this profile carries exactly **one CoMID tag** with +//! the following structure: +//! +//! ## Environment (`class-map`) +//! +//! The Target Environment is identified by `vendor` (key 1) and `model` +//! (key 2) in the `class-map`. No `instance` or `group` is used -- this is +//! a class-level endorsement that applies to all instances of the platform. +//! +//! Registered vendor/model pairs: +//! +//! | IgvmPlatformType | Vendor | Model | Digest Algorithm | Digest Length | +//! |------------------|---------------|-------------|------------------|---------------| +//! | TDX | `"Intel"` | `"TDX"` | SHA-384 (7) | 48 bytes | +//! | SEV-SNP | `"AMD"` | `"SEV-SNP"` | SHA-384 (7) | 48 bytes | +//! | VSM_ISOLATION | `"Microsoft"` | `"VBS"` | SHA-256 (1) | 32 bytes | +//! +//! ## Tag Identity (`tag-identity-map`) +//! +//! The `tag-id` is derived deterministically via UUIDv5 and encoded as a +//! **lowercase** hyphenated string per RFC 9562 Sec. 4: +//! +//! ```text +//! tag-id = lowercase(UUIDv5("85f3f1c2-22a8-441e-a1b9-bccfb63ed5f7", "{vendor}/{model}")) +//! ``` +//! +//! Validators MUST compare tag-ids **case-insensitively** for interoperability. +//! +//! ## Triples +//! +//! ### Required: `reference-triples` (key 0) +//! +//! Exactly one `reference-triple-record` containing: +//! - `environment`: `{ class: { vendor, model } }` +//! - `measurements`: one `measurement-map` with: +//! - `mkey`: text string identifying the evidence field (e.g., `"MRTD"` for TDX, `"MEASUREMENT"` for SEV-SNP and VBS) +//! - `mval.digests`: one `[alg-id, hash-bytes]` pair +//! +//! ### Required: `conditional-endorsement-series-triples` (key 8) +//! +//! Exactly one `conditional-endorsement-series-triple-record` containing: +//! - `condition`: the same environment, with an empty `claims-list` +//! - `series`: one entry mapping the digest to an exact SVN (`#6.552(uint)`) +//! +//! ## Constraints +//! +//! - The CoRIM MUST include the profile URI (`corim-map` key 3) set to +//! [`PROFILE_URI`]. Documents without the profile field MUST be rejected. +//! - The CoRIM MUST contain exactly one CoMID tag (`#6.506`). +//! - The CoMID `tag-id` MUST equal the lowercase string form of +//! `UUIDv5(TAG_ID_NAMESPACE, "{vendor}/{model}")` per RFC 9562 Sec. 4. +//! Validators MUST compare tag-ids case-insensitively. +//! - The CoMID MUST contain both `reference-triples` and +//! `conditional-endorsement-series-triples`. +//! - Only exact SVN (`#6.552`) is permitted. Minimum SVN (`#6.553`) and +//! untagged integers MUST be rejected. +//! - The vendor/model MUST match one of the registered platform pairs. +//! - The digest algorithm and length MUST match the platform's expected values. +//! - CBOR deterministic encoding SHOULD be used for map keys (already +//! ensured by emitting integer keys in ascending order). + +/// The profile URI for launch measurement CoRIM documents. +/// +/// This URI is set in `corim-map` key 3 (`profile`) and signals to a +/// verifier that the document conforms to the constraints above. +/// +/// Format follows the tag URI scheme (RFC 4151): +/// `tag:,:` +pub const PROFILE_URI: &str = "tag:microsoft.com,2026:launch-measurement/v1"; diff --git a/igvm/src/corim/mod.rs b/igvm/src/corim/mod.rs new file mode 100644 index 0000000..b1835b7 --- /dev/null +++ b/igvm/src/corim/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! CoRIM (Concise Reference Integrity Manifest) support for IGVM. +//! +//! This module provides CoRIM document generation for IGVM attestation. +//! +//! Built on top of the [`corim`](https://github.com/Azure/corim) crate +//! for typed CoRIM/CoMID structures, CBOR encoding, and structural +//! validation per draft-ietf-rats-corim-10. +//! +//! # Modules +//! +//! - [`launch_measurement`] -- The launch measurement profile + +pub mod launch_measurement; + +// Re-export launch_measurement types for convenience. +pub use launch_measurement::Error; diff --git a/igvm/src/lib.rs b/igvm/src/lib.rs index 8dcb8ad..8405aeb 100644 --- a/igvm/src/lib.rs +++ b/igvm/src/lib.rs @@ -40,6 +40,24 @@ use zerocopy::KnownLayout; #[cfg(feature = "igvm-c")] pub mod c_api; +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +pub mod corim; + +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +pub mod measurement; + +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +mod serializer; +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +pub use serializer::IgvmPlatformMeasurement; +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +pub use serializer::IgvmSerializer; + pub mod hv_defs; pub mod page_table; pub mod registers; @@ -1945,7 +1963,7 @@ impl IgvmDirectiveHeader { return Err(BinaryHeaderError::UnalignedAddress(*gpa)); } - if *number_of_bytes as u64 % PAGE_SIZE_4K != 0 { + if (*number_of_bytes as u64) % PAGE_SIZE_4K != 0 { return Err(BinaryHeaderError::UnalignedSize(*number_of_bytes as u64)); } } @@ -2458,6 +2476,19 @@ pub enum Error { InvalidFixedHeaderArch(u32), #[error("merged igvm files are not the same revision")] MergeRevision, + #[cfg(feature = "corim")] + #[error("CoRIM generation failed: {0}")] + CorimGeneration(String), + #[cfg(feature = "corim")] + #[error("measurement computation failed: {0}")] + MeasurementFailed(String), +} + +#[cfg(feature = "corim")] +impl From for Error { + fn from(e: crate::corim::launch_measurement::Error) -> Self { + Error::CorimGeneration(e.to_string()) + } } /// Architecture for an IGVM file. @@ -2679,6 +2710,45 @@ impl FixedHeader { } } +/// Pre-defined CoRIM document templates for IGVM. +/// +/// Each variant represents a fixed CoRIM structure with +/// well-defined semantics. The caller supplies only the variable parameters, +/// and the template determines the full CBOR layout. +/// +/// Used with [`IgvmSerializer::add_corim`]. +/// +/// # Future extensibility +/// +/// New CoRIM profiles (e.g., the Intel TDX profile) are added as new +/// variants of this enum; [`IgvmSerializer::add_corim`] grows a new +/// match arm to dispatch to the corresponding profile-specific builder. +/// +/// This enum is intentionally the abstraction boundary instead of a +/// `trait CorimProfile` -- when a second profile lands, we'll have +/// concrete data to decide whether shared behavior justifies introducing +/// a trait. Until then, two concrete profile types side-by-side keep the +/// API surface small and avoid prematurely encoding launch-measurement +/// assumptions (single platform, single SVN, CES-only) into a generic +/// shape. +#[cfg(feature = "corim")] +#[cfg_attr(docsrs, doc(cfg(feature = "corim")))] +#[derive(Debug, Clone)] +pub enum CorimTemplate { + /// A CoRIM produced by the launch measurement profile. + /// + /// Built via the two-stage + /// [`LaunchMeasurement`](crate::corim::launch_measurement::LaunchMeasurement) + /// builder; finalize with + /// [`LaunchMeasurement::build`](crate::corim::launch_measurement::LaunchMeasurement::build) + /// to obtain this variant. + LaunchMeasurement(crate::corim::launch_measurement::LaunchMeasurement), + /// The architectural CoRIM template defined by vendors. + Architectural, + /// A custom CoRIM template with user-provided bytes. + Custom(Vec), +} + impl IgvmFile { /// Check if the given platform headers are valid. /// @@ -3465,6 +3535,12 @@ impl IgvmFile { self.initialization_headers.as_slice() } + /// Get a mutable reference to the initialization headers in this file. + #[cfg(feature = "corim")] + pub(crate) fn initializations_mut(&mut self) -> &mut Vec { + &mut self.initialization_headers + } + /// Get the directive headers in this file. pub fn directives(&self) -> &[IgvmDirectiveHeader] { self.directive_headers.as_slice() diff --git a/igvm/src/measurement/mod.rs b/igvm/src/measurement/mod.rs new file mode 100644 index 0000000..9656275 --- /dev/null +++ b/igvm/src/measurement/mod.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! Launch measurement calculation for CVM platforms. +//! +//! Computes platform-specific launch digests from an IGVM file's headers, +//! matching the measurement algorithms used by the hardware/firmware: +//! +//! - **AMD SEV-SNP**: Iterates page data, chaining SHA-384 hashes through +//! `SnpPageInfo` structures per the AMD SEV-SNP firmware ABI. +//! - **Intel TDX**: Computes MRTD by hashing `MEM.PAGE.ADD` and `MR.EXTEND` +//! operations per the Intel TDX module specification. +//! - **Microsoft VBS**: Computes a SHA-256 boot measurement digest by hashing +//! page chunks and VP register state per the VBS measurement protocol. + +mod snp; +mod tdx; +mod vbs; + +pub use snp::generate_snp_measurement; +pub use tdx::generate_tdx_measurement; +pub use vbs::generate_vbs_measurement; + +pub(crate) const SHA_256_OUTPUT_SIZE: usize = 32; +pub(crate) const SHA_384_OUTPUT_SIZE: usize = 48; + +/// Errors from measurement calculation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum MeasurementError { + /// A parameter area index referenced by a ParameterInsert was not found. + #[error("invalid parameter area index {0}")] + InvalidParameterAreaIndex(u32), + /// No guest policy found in initialization headers (SNP only). + #[error("no SNP guest policy found in initialization headers")] + MissingGuestPolicy, + /// Page data larger than 4K is not supported. + #[error("unsupported page data size: {0} bytes")] + UnsupportedPageSize(usize), +} diff --git a/igvm/src/measurement/snp.rs b/igvm/src/measurement/snp.rs new file mode 100644 index 0000000..b467978 --- /dev/null +++ b/igvm/src/measurement/snp.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! AMD SEV-SNP launch measurement (launch digest) calculation. +//! +//! Computes the launch digest by iterating IGVM directive headers and chaining +//! SHA-384 hashes through `SnpPageInfo` structures, matching the SNP firmware's +//! measurement algorithm. + +use super::MeasurementError; +use super::SHA_384_OUTPUT_SIZE; +use crate::IgvmDirectiveHeader; +use crate::IgvmInitializationHeader; +use igvm_defs::IgvmPageDataType; +use igvm_defs::PAGE_SIZE_4K; +use sha2::Digest; +use sha2::Sha384; +use std::collections::HashMap; +use zerocopy::FromBytes; +use zerocopy::Immutable; +use zerocopy::IntoBytes; +use zerocopy::KnownLayout; + +const PAGE_SIZE_4K_USIZE: usize = PAGE_SIZE_4K as usize; + +// Local type definitions matching the AMD SEV-SNP firmware ABI. + +/// SNP page type constants. +mod snp_page_type { + pub const NORMAL: u8 = 0x1; + pub const VMSA: u8 = 0x2; + pub const UNMEASURED: u8 = 0x4; + pub const SECRETS: u8 = 0x5; + pub const CPUID: u8 = 0x6; +} + +/// Structure used by SNP firmware to chain page measurements. +/// +/// See AMD SEV-SNP firmware ABI specification Sec. 7.3 (SNP_LAUNCH_UPDATE). +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout, FromBytes)] +struct SnpPageInfo { + digest_current: [u8; 48], + contents: [u8; 48], + length: u16, + page_type: u8, + imi_page_bit: u8, + lower_vmpl_permissions: u32, + gpa: u64, +} + +/// Compute the SNP launch digest from IGVM headers. +/// +/// Iterates all initialization and directive headers for the given +/// compatibility mask, computing the chained SHA-384 measurement that +/// the SNP firmware would produce during `SNP_LAUNCH_UPDATE`. +/// +/// The IGVM file must contain an [`IgvmInitializationHeader::GuestPolicy`] +/// matching `compatibility_mask`; otherwise [`MeasurementError::MissingGuestPolicy`] +/// is returned. The policy itself does not affect the returned digest. +pub fn generate_snp_measurement( + initialization_headers: &[IgvmInitializationHeader], + directive_headers: &[IgvmDirectiveHeader], + compatibility_mask: u32, +) -> Result<[u8; SHA_384_OUTPUT_SIZE], MeasurementError> { + let mut parameter_area_table = HashMap::new(); + let mut launch_digest = [0u8; SHA_384_OUTPUT_SIZE]; + + // Pre-compute hash of zero page (used when file does not carry data) + let zero_page = [0u8; PAGE_SIZE_4K_USIZE]; + let zero_digest: [u8; SHA_384_OUTPUT_SIZE] = { + let mut h = Sha384::new(); + h.update(zero_page); + h.finalize().into() + }; + + let mut padding_vec = vec![0u8; PAGE_SIZE_4K_USIZE]; + + let mut measure_page = + |page_type: u8, gpa: u64, page_data: Option<&[u8]>| -> Result<(), MeasurementError> { + let hash_contents: [u8; SHA_384_OUTPUT_SIZE] = match page_data { + Some(data) => match data.len() { + 0 => zero_digest, + len if len < PAGE_SIZE_4K_USIZE => { + padding_vec.fill(0); + padding_vec[..len].copy_from_slice(data); + let mut h = Sha384::new(); + h.update(&padding_vec); + h.finalize().into() + } + PAGE_SIZE_4K_USIZE => { + let mut h = Sha384::new(); + h.update(data); + h.finalize().into() + } + len => return Err(MeasurementError::UnsupportedPageSize(len)), + }, + None => [0u8; SHA_384_OUTPUT_SIZE], + }; + + let info = SnpPageInfo { + digest_current: launch_digest, + contents: hash_contents, + length: size_of::() as u16, + page_type, + imi_page_bit: 0, + lower_vmpl_permissions: 0, + gpa, + }; + + let mut h = Sha384::new(); + h.update(info.as_bytes()); + launch_digest = h.finalize().into(); + Ok(()) + }; + + // Validate that the file carries an SNP guest policy. The policy itself + // does not affect the launch digest, but a malformed file without one + // would be unusable downstream. + let _policy = initialization_headers + .iter() + .find_map(|h| { + if let IgvmInitializationHeader::GuestPolicy { + policy, + compatibility_mask: mask, + } = h + { + if mask & compatibility_mask == compatibility_mask { + return Some(*policy); + } + } + None + }) + .ok_or(MeasurementError::MissingGuestPolicy)?; + + // Iterate directive headers + for header in directive_headers { + if header + .compatibility_mask() + .map(|mask| mask & compatibility_mask != compatibility_mask) + .unwrap_or(false) + { + continue; + } + + match header { + IgvmDirectiveHeader::ParameterArea { + number_of_bytes, + parameter_area_index, + initial_data: _, + } => { + parameter_area_table.insert(*parameter_area_index, *number_of_bytes); + } + IgvmDirectiveHeader::PageData { + gpa, + flags, + data_type, + data, + .. + } => { + if flags.shared() { + continue; + } + + let (page_type, data) = match *data_type { + IgvmPageDataType::SECRETS => (snp_page_type::SECRETS, None), + IgvmPageDataType::CPUID_DATA | IgvmPageDataType::CPUID_XF => { + (snp_page_type::CPUID, None) + } + _ => { + if flags.unmeasured() { + (snp_page_type::UNMEASURED, None) + } else { + (snp_page_type::NORMAL, Some(data.as_slice())) + } + } + }; + + measure_page(page_type, *gpa, data)?; + } + IgvmDirectiveHeader::ParameterInsert(param) => { + let parameter_area_size = parameter_area_table + .get(¶m.parameter_area_index) + .ok_or(MeasurementError::InvalidParameterAreaIndex( + param.parameter_area_index, + ))?; + + for gpa in (param.gpa..param.gpa + *parameter_area_size).step_by(PAGE_SIZE_4K_USIZE) + { + measure_page(snp_page_type::UNMEASURED, gpa, None)?; + } + } + IgvmDirectiveHeader::SnpVpContext { gpa, vmsa, .. } => { + let vmsa_bytes = vmsa.as_ref().as_bytes(); + measure_page(snp_page_type::VMSA, *gpa, Some(vmsa_bytes))?; + } + _ => {} + } + } + + Ok(launch_digest) +} diff --git a/igvm/src/measurement/tdx.rs b/igvm/src/measurement/tdx.rs new file mode 100644 index 0000000..a9cd283 --- /dev/null +++ b/igvm/src/measurement/tdx.rs @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! Intel TDX launch measurement (MRTD) calculation. +//! +//! Computes the MRTD by iterating IGVM directive headers and hashing +//! `MEM.PAGE.ADD` and `MR.EXTEND` operations, matching the TDX module's +//! measurement algorithm. + +use super::MeasurementError; +use super::SHA_384_OUTPUT_SIZE; +use crate::IgvmDirectiveHeader; +use igvm_defs::PAGE_SIZE_4K; +use sha2::Digest; +use sha2::Sha384; +use std::collections::HashMap; +use zerocopy::FromBytes; +use zerocopy::Immutable; +use zerocopy::IntoBytes; +use zerocopy::KnownLayout; + +const PAGE_SIZE_4K_USIZE: usize = PAGE_SIZE_4K as usize; +const TDX_EXTEND_CHUNK_SIZE: usize = 256; + +/// Structure for measuring a page addition to the TD. +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout, FromBytes)] +struct TdxPageAdd { + /// `MEM.PAGE.ADD` operation identifier. + operation: [u8; 16], + /// Guest physical address (page-aligned). + gpa: u64, + /// Reserved, must be zero. + mbz: [u8; 104], +} + +/// Structure for measuring a 256-byte data chunk. +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout, FromBytes)] +struct TdxMrExtend { + /// `MR.EXTEND` operation identifier. + operation: [u8; 16], + /// Guest physical address (256-byte aligned). + gpa: u64, + /// Reserved, must be zero. + mbz: [u8; 104], + /// 256 bytes of data to measure. + data: [u8; TDX_EXTEND_CHUNK_SIZE], +} + +/// Compute the TDX MRTD from IGVM directive headers. +/// +/// Iterates all directive headers for the given compatibility mask, +/// computing the running SHA-384 hash of `MEM.PAGE.ADD` and `MR.EXTEND` +/// operations that the TDX module would perform during `TDH.MR.FINALIZE`. +pub fn generate_tdx_measurement( + directive_headers: &[IgvmDirectiveHeader], + compatibility_mask: u32, +) -> Result<[u8; SHA_384_OUTPUT_SIZE], MeasurementError> { + let mut parameter_area_table = HashMap::new(); + let mut padding_vec = vec![0u8; PAGE_SIZE_4K_USIZE]; + let mut hasher = Sha384::new(); + + let mut measure_page = |gpa: u64, page_data: Option<&[u8]>| -> Result<(), MeasurementError> { + // Measure the page being added. + let page_add = TdxPageAdd { + operation: *b"MEM.PAGE.ADD\0\0\0\0", + gpa, + mbz: [0; 104], + }; + hasher.update(page_add.as_bytes()); + + // Possibly measure the page contents in 256-byte chunks. + if let Some(data) = page_data { + let data = match data.len() { + 0 => None, + PAGE_SIZE_4K_USIZE => Some(data), + len if len < PAGE_SIZE_4K_USIZE => { + padding_vec.fill(0); + padding_vec[..len].copy_from_slice(data); + Some(padding_vec.as_slice()) + } + len => return Err(MeasurementError::UnsupportedPageSize(len)), + }; + + for offset in (0..PAGE_SIZE_4K).step_by(TDX_EXTEND_CHUNK_SIZE) { + let mut mr_extend = TdxMrExtend { + operation: *b"MR.EXTEND\0\0\0\0\0\0\0", + gpa: gpa + offset, + mbz: [0; 104], + data: [0; TDX_EXTEND_CHUNK_SIZE], + }; + + if let Some(data) = data { + mr_extend.data.copy_from_slice( + &data[offset as usize..offset as usize + TDX_EXTEND_CHUNK_SIZE], + ); + } + hasher.update(mr_extend.as_bytes()); + } + } + Ok(()) + }; + + for header in directive_headers { + if header + .compatibility_mask() + .map(|mask| mask & compatibility_mask != compatibility_mask) + .unwrap_or(false) + { + continue; + } + + match header { + IgvmDirectiveHeader::ParameterArea { + number_of_bytes, + parameter_area_index, + initial_data: _, + } => { + parameter_area_table.insert(*parameter_area_index, *number_of_bytes); + } + IgvmDirectiveHeader::PageData { + gpa, flags, data, .. + } => { + if flags.shared() { + continue; + } + + let data = if flags.unmeasured() { + None + } else { + Some(data.as_slice()) + }; + + measure_page(*gpa, data)?; + } + IgvmDirectiveHeader::ParameterInsert(param) => { + let parameter_area_size = parameter_area_table + .get(¶m.parameter_area_index) + .ok_or(MeasurementError::InvalidParameterAreaIndex( + param.parameter_area_index, + ))?; + + for gpa in (param.gpa..param.gpa + *parameter_area_size).step_by(PAGE_SIZE_4K_USIZE) + { + measure_page(gpa, None)?; + } + } + _ => {} + } + } + + Ok(hasher.finalize().into()) +} diff --git a/igvm/src/measurement/vbs.rs b/igvm/src/measurement/vbs.rs new file mode 100644 index 0000000..242e085 --- /dev/null +++ b/igvm/src/measurement/vbs.rs @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! Microsoft VBS launch measurement (boot digest) calculation. +//! +//! Computes the VBS boot measurement digest by iterating IGVM directive +//! headers and hashing page chunks and VP register state using SHA-256, +//! matching the VBS measurement protocol. +//! + +#![expect(non_camel_case_types)] + +use super::MeasurementError; +use super::SHA_256_OUTPUT_SIZE; +use crate::IgvmDirectiveHeader; +use bitfield_struct::bitfield; +use igvm_defs::IgvmPageDataType; +use igvm_defs::VbsVpContextRegister; +use igvm_defs::PAGE_SIZE_4K; +use open_enum::open_enum; +use sha2::Digest; +use sha2::Sha256; +use static_assertions::const_assert; +use std::collections::HashMap; +use zerocopy::Immutable; +use zerocopy::IntoBytes; +use zerocopy::KnownLayout; + +const PAGE_SIZE_4K_USIZE: usize = PAGE_SIZE_4K as usize; + +/// Full chunk size for VBS measurement (chunk header + page data). +const VBS_VP_CHUNK_SIZE_BYTES: usize = PAGE_SIZE_4K_USIZE + size_of::(); + +/// Acceptance flag indicating a GPA page is readable. +const VM_GPA_PAGE_READABLE: u64 = 0x1; +/// Acceptance flag indicating a GPA page is writable. +const VM_GPA_PAGE_WRITABLE: u64 = 0x2; + +/// Chunk that is measured to generate digest. These consist of a 16 byte header +/// followed by data. This needs c style alignment to generate a consistent +/// measurement. Defined by the following struct in C: +/// ``` ignore +/// typedef struct _VBS_VM_BOOT_MEASUREMENT_CHUNK +/// { +/// UINT32 ByteCount; +/// VBS_VM_BOOT_MEASUREMENT_CHUNK_TYPE Type; +/// UINT64 Reserved; +/// +/// union +/// { +/// VBS_VM_BOOT_MEASUREMENT_CHUNK_VP_REGISTER VpRegister; +/// VBS_VM_BOOT_MEASUREMENT_CHUNK_VP_VTL_ENABLED VpVtlEnabled; +/// VBS_VM_BOOT_MEASUREMENT_CHUNK_GPA_PAGE GpaPage; +/// } u; +/// } VBS_VM_BOOT_MEASUREMENT_CHUNK, *PVBS_VM_BOOT_MEASUREMENT_CHUNK; +/// ``` +/// +/// Structure describing the chunk to be measured. +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout)] +struct VbsChunkHeader { + /// The full size to be measured + byte_count: u32, + chunk_type: BootMeasurementType, + reserved: u64, +} + +/// Structure describing the register being measured. Will be padded to +/// [`VBS_VP_CHUNK_SIZE_BYTES`] when hashed to generate digest. +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout)] +struct VbsRegisterChunk { + header: VbsChunkHeader, + reserved: u32, + vtl: u8, + reserved2: u8, + reserved3: u16, + reserved4: u32, + name: u32, + value: [u8; 16], +} +const_assert!(size_of::() <= VBS_VP_CHUNK_SIZE_BYTES); + +/// Structure describing the page to be measured. +/// Page data is hashed after struct to generate digest, if not a full page, +/// measurable data will be padded to [`VBS_VP_CHUNK_SIZE_BYTES`]. +#[repr(C)] +#[derive(IntoBytes, Immutable, KnownLayout)] +struct VpGpaPageChunk { + header: VbsChunkHeader, + metadata: u64, + page_number: u64, +} + +#[open_enum] +#[derive(IntoBytes, Immutable, KnownLayout, Clone, Copy)] +#[repr(u32)] +enum BootMeasurementType { + VP_REGISTER = 0, + VP_VTL_ENABLED = 1, + VP_GPA_PAGE = 2, +} + +/// Flags indicating read and write acceptance of a GPA Page and whether it is +/// to be measured in the digest. +#[bitfield(u64)] +struct VBS_VM_GPA_PAGE_BOOT_METADATA { + #[bits(2)] + acceptance: u64, + #[bits(1)] + data_unmeasured: bool, + #[bits(61)] + reserved: u64, +} + +/// Compute the VBS boot measurement digest from IGVM directive headers. +/// +/// Iterates all directive headers for the given compatibility mask, computing +/// the chained SHA-256 measurement that VBS firmware would produce. +pub fn generate_vbs_measurement( + directive_headers: &[IgvmDirectiveHeader], + compatibility_mask: u32, +) -> Result<[u8; SHA_256_OUTPUT_SIZE], MeasurementError> { + let mut digest = VbsDigestor::new(); + let mut parameter_area_table = HashMap::new(); + let mut bsp_regs: Vec> = Vec::new(); + + for header in directive_headers { + // Skip headers that have compatibility masks that do not match vbs. + if header + .compatibility_mask() + .map(|mask| mask & compatibility_mask != compatibility_mask) + .unwrap_or(false) + { + continue; + } + + match header { + IgvmDirectiveHeader::PageData { + gpa, + compatibility_mask: _, + flags, + data_type, + data, + } => { + assert_eq!(*data_type, IgvmPageDataType::NORMAL); + + // Skip shared pages. + if flags.shared() { + continue; + } + + let boot_metadata = VBS_VM_GPA_PAGE_BOOT_METADATA::new() + .with_acceptance(0) + .with_data_unmeasured(flags.unmeasured()); + digest.record_gpa_page(gpa / PAGE_SIZE_4K, 1, boot_metadata, data); + } + IgvmDirectiveHeader::ParameterInsert(param) => { + let page_metadata = VBS_VM_GPA_PAGE_BOOT_METADATA::new() + .with_acceptance(0) + .with_data_unmeasured(true); + let parameter_area_size = parameter_area_table + .get(¶m.parameter_area_index) + .ok_or(MeasurementError::InvalidParameterAreaIndex( + param.parameter_area_index, + ))?; + digest.record_gpa_page( + param.gpa / PAGE_SIZE_4K, + parameter_area_size / PAGE_SIZE_4K, + page_metadata, + &[], + ); + } + IgvmDirectiveHeader::X64VbsVpContext { + vtl, + registers, + compatibility_mask: _, + } => { + // The VBS measurement format requires the cpu context to be + // measured last; collect now and apply at the end. + let vtl_registers: Vec = registers + .iter() + .map(|r| r.into_vbs_vp_context_reg(*vtl)) + .collect(); + bsp_regs.push(vtl_registers); + } + IgvmDirectiveHeader::AArch64VbsVpContext { + vtl, + registers, + compatibility_mask: _, + } => { + // The VBS measurement format requires the cpu context to be + // measured last; collect now and apply at the end. + let vtl_registers: Vec = registers + .iter() + .map(|r| r.into_vbs_vp_context_reg(*vtl)) + .collect(); + bsp_regs.push(vtl_registers); + } + IgvmDirectiveHeader::ErrorRange { + gpa, + compatibility_mask: _, + size_bytes, + } => { + let page_metadata = VBS_VM_GPA_PAGE_BOOT_METADATA::new() + .with_acceptance(VM_GPA_PAGE_READABLE | VM_GPA_PAGE_WRITABLE) + .with_data_unmeasured(true); + digest.record_gpa_page( + *gpa / PAGE_SIZE_4K, + (*size_bytes as u64).div_ceil(PAGE_SIZE_4K), + page_metadata, + &[], + ); + } + IgvmDirectiveHeader::ParameterArea { + number_of_bytes, + parameter_area_index, + initial_data: _, + } => { + if parameter_area_table.contains_key(parameter_area_index) { + return Err(MeasurementError::InvalidParameterAreaIndex( + *parameter_area_index, + )); + } + parameter_area_table.insert(*parameter_area_index, *number_of_bytes); + } + _ => {} + } + } + + // Measure all registers in each VTL as last step in measurement. + for set in bsp_regs { + for reg in set { + digest.record_vp_register(reg); + } + } + + Ok(digest.finish_digest()) +} + +struct VbsDigestor { + digest: [u8; SHA_256_OUTPUT_SIZE], +} + +impl VbsDigestor { + fn new() -> Self { + Self { + digest: [0; SHA_256_OUTPUT_SIZE], + } + } + + fn record_gpa_page( + &mut self, + gpa_page_base: u64, + page_count: u64, + page_metadata: VBS_VM_GPA_PAGE_BOOT_METADATA, + mut data: &[u8], + ) { + for page in 0..page_count { + let import_data_len: usize = match page_metadata.data_unmeasured() { + true => 0, + false => std::cmp::min(PAGE_SIZE_4K_USIZE, data.len()), + }; + let (import_data, data_remaining) = data.split_at(import_data_len); + data = data_remaining; + + // If page is under 4K bytes, pad to full length which will be + // hashed with page and chunk data. + let padding = vec![0u8; PAGE_SIZE_4K_USIZE - import_data.len()]; + let page_number = gpa_page_base + page; + let chunk = VpGpaPageChunk { + header: VbsChunkHeader { + byte_count: VBS_VP_CHUNK_SIZE_BYTES as u32, + chunk_type: BootMeasurementType::VP_GPA_PAGE, + reserved: 0, + }, + metadata: page_metadata.into(), + page_number, + }; + self.create_record_entry(&[chunk.as_bytes(), import_data, &padding]); + } + } + + fn record_vp_register(&mut self, reg: VbsVpContextRegister) { + let chunk = VbsRegisterChunk { + header: VbsChunkHeader { + byte_count: size_of::() as u32, + chunk_type: BootMeasurementType::VP_REGISTER, + reserved: 0, + }, + reserved: 0, + vtl: reg.vtl, + reserved2: 0, + reserved3: 0, + reserved4: 0, + name: reg.register_name.into(), + value: reg.register_value, + }; + self.create_record_entry(&[chunk.as_bytes()]); + } + + fn create_record_entry(&mut self, chunks: &[&[u8]]) { + let mut hasher = Sha256::new(); + hasher.update(self.digest.as_slice()); + for chunk in chunks { + hasher.update(chunk); + } + self.digest = hasher.finalize().into(); + } + + fn finish_digest(&self) -> [u8; SHA_256_OUTPUT_SIZE] { + self.digest + } +} diff --git a/igvm/src/serializer.rs b/igvm/src/serializer.rs new file mode 100644 index 0000000..5a734d2 --- /dev/null +++ b/igvm/src/serializer.rs @@ -0,0 +1,814 @@ +// SPDX-License-Identifier: MIT +// +// Copyright (c) Microsoft Corporation. + +//! IGVM file serializer with support for computing launch measurements +//! and attaching CoRIM documents before writing to binary format. +//! +//! [`IgvmSerializer`] borrows an immutable [`IgvmFile`] and provides a +//! builder-style API for enriching the output with per-platform launch +//! measurements and CoRIM documents, without mutating the original file. +//! +//! # Example +//! +//! ```rust,no_run +//! use igvm::corim::launch_measurement::LaunchMeasurement; +//! use igvm::corim::launch_measurement::MeasurementKind; +//! use igvm::IgvmFile; +//! use igvm::IgvmSerializer; +//! use igvm_defs::IgvmPlatformType; +//! +//! # fn example(file: &IgvmFile) -> Result<(), igvm::Error> { +//! // Construction eagerly computes the launch measurement for every +//! // measurable platform header in the file. +//! let mut serializer = IgvmSerializer::new(file)?; +//! +//! // Inspect the SNP measurement (already cached from `new`). +//! if let Some(m) = serializer.measurement_for(IgvmPlatformType::SEV_SNP) { +//! println!("SNP digest: {}", hex::encode(&m.digest)); +//! } +//! +//! // Stage 1: populate the launch measurement. +//! let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP)?; +//! le.set_measurement(MeasurementKind::Launch)?; +//! +//! // Stage 2: build a CES triple that endorses SVN 1 when the launch +//! // measurement matches. +//! le.endorse(1).with(MeasurementKind::Launch)?.finish()?; +//! +//! serializer.add_corim(IgvmPlatformType::SEV_SNP, le.build())?; +//! +//! // Serialize to binary +//! let mut output = Vec::new(); +//! serializer.serialize(&mut output)?; +//! # Ok(()) +//! # } +//! ``` + +use crate::CorimTemplate; +use crate::Error; +use crate::IgvmFile; +use crate::IgvmInitializationHeader; +use crate::IgvmPlatformHeader; +use igvm_defs::IgvmPlatformType; + +/// A per-platform launch measurement computed from an IGVM file's headers. +#[derive(Debug, Clone)] +pub struct IgvmPlatformMeasurement { + /// The platform type this measurement was computed for. + pub platform: IgvmPlatformType, + /// The compatibility mask associated with this platform. + pub compatibility_mask: u32, + /// The raw launch measurement digest bytes. + /// + /// Length depends on the platform: + /// - SEV-SNP: 48 bytes (SHA-384) + /// - TDX: 48 bytes (SHA-384) + /// - VBS: 32 bytes (SHA-256) + pub digest: Vec, +} + +/// Serializer that borrows an [`IgvmFile`] and enriches the output with +/// computed measurements and CoRIM documents. +/// +/// The underlying [`IgvmFile`] is never mutated. Additional initialization +/// headers (CoRIM documents) are accumulated in the serializer and merged +/// into the output during [`serialize`](IgvmSerializer::serialize). +#[derive(Debug)] +pub struct IgvmSerializer<'a> { + file: &'a IgvmFile, + measurements: Vec, + extra_init_headers: Vec, +} + +impl<'a> IgvmSerializer<'a> { + /// Create a new serializer for the given IGVM file. + /// + /// During construction, the launch measurement is computed for every + /// platform header in the file whose platform type has a measurement + /// profile defined by this crate (currently SEV-SNP, TDX, and VBS). + /// Platform headers with no measurement profile (e.g., + /// [`IgvmPlatformType::NATIVE`], [`IgvmPlatformType::SEV`], + /// [`IgvmPlatformType::SEV_ES`]) are silently skipped. + /// + /// # Errors + /// + /// Returns [`Error::MeasurementFailed`] if measurement computation + /// fails for any of the file's measurable platforms (e.g., SEV-SNP + /// without a [`IgvmInitializationHeader::GuestPolicy`] header). + pub fn new(file: &'a IgvmFile) -> Result { + let mut serializer = Self { + file, + measurements: Vec::new(), + extra_init_headers: Vec::new(), + }; + + // Eagerly compute the launch measurement for every supported + // platform present in the file. + let platforms: Vec = file + .platforms() + .iter() + .filter_map(|h| match h { + IgvmPlatformHeader::SupportedPlatform(info) + if Self::is_measurable(info.platform_type) => + { + Some(info.platform_type) + } + _ => None, + }) + .collect(); + for platform in platforms { + serializer.compute_measurement(platform)?; + } + + Ok(serializer) + } + + /// Returns `true` if this crate's measurement profile knows how to + /// hash an IGVM file for the given platform type. + fn is_measurable(platform: IgvmPlatformType) -> bool { + matches!( + platform, + IgvmPlatformType::SEV_SNP | IgvmPlatformType::TDX | IgvmPlatformType::VSM_ISOLATION + ) + } + + /// Get a reference to the underlying IGVM file. + pub fn file(&self) -> &IgvmFile { + self.file + } + + /// Get all launch measurements computed for this file. + /// + /// One entry is present for every measurable platform header in the + /// file (see [`new`](Self::new)). + pub fn measurements(&self) -> &[IgvmPlatformMeasurement] { + &self.measurements + } + + /// Get the launch measurement for a specific platform, if the file + /// has a corresponding measurable platform header. + pub fn measurement_for(&self, platform: IgvmPlatformType) -> Option<&IgvmPlatformMeasurement> { + self.measurements.iter().find(|m| m.platform == platform) + } + + /// Get the raw CoRIM document bytes attached for the given platform, + /// if one was previously added via [`add_corim`](Self::add_corim). + /// + /// Returns `None` if the file has no platform header for `platform`, + /// or if no CoRIM has been attached for it yet. + #[cfg(feature = "corim")] + #[cfg_attr(docsrs, doc(cfg(feature = "corim")))] + pub fn corim_for(&self, platform: IgvmPlatformType) -> Option<&[u8]> { + let compatibility_mask = self.lookup_compatibility_mask(platform).ok()?; + self.extra_init_headers.iter().find_map(|h| match h { + IgvmInitializationHeader::CorimDocument { + compatibility_mask: mask, + document, + } if *mask == compatibility_mask => Some(document.as_slice()), + _ => None, + }) + } + + /// Look up the compatibility mask for a platform type from the file's + /// platform headers. + fn lookup_compatibility_mask(&self, platform: IgvmPlatformType) -> Result { + self.file + .platforms() + .iter() + .find_map(|h| match h { + IgvmPlatformHeader::SupportedPlatform(info) if info.platform_type == platform => { + Some(info.compatibility_mask) + } + _ => None, + }) + .ok_or_else(|| { + Error::MeasurementFailed(format!("no platform header found for {platform:?}")) + }) + } + + /// Internal: compute the launch measurement for a specific platform + /// and append it to the cache. Called eagerly from + /// [`new`](Self::new) for every measurable platform header. + #[cfg(feature = "corim")] + fn compute_measurement(&mut self, platform: IgvmPlatformType) -> Result<(), Error> { + debug_assert!( + !self.measurements.iter().any(|m| m.platform == platform), + "compute_measurement called twice for {platform:?}" + ); + + let compatibility_mask = self.lookup_compatibility_mask(platform)?; + + let digest = match platform { + IgvmPlatformType::SEV_SNP => crate::measurement::generate_snp_measurement( + self.file.initializations(), + self.file.directives(), + compatibility_mask, + ) + .map_err(|e| Error::MeasurementFailed(e.to_string()))? + .to_vec(), + IgvmPlatformType::TDX => crate::measurement::generate_tdx_measurement( + self.file.directives(), + compatibility_mask, + ) + .map_err(|e| Error::MeasurementFailed(e.to_string()))? + .to_vec(), + IgvmPlatformType::VSM_ISOLATION => crate::measurement::generate_vbs_measurement( + self.file.directives(), + compatibility_mask, + ) + .map_err(|e| Error::MeasurementFailed(e.to_string()))? + .to_vec(), + _ => { + return Err(Error::MeasurementFailed(format!( + "unsupported platform type for measurement: {platform:?}" + ))) + } + }; + + self.measurements.push(IgvmPlatformMeasurement { + platform, + compatibility_mask, + digest, + }); + + Ok(()) + } + + /// Attach a CoRIM endorsement for the given platform. + /// + /// The generated CoRIM document will be included as an + /// [`IgvmInitializationHeader::CorimDocument`] in the serialized output. + /// On success, the raw CoRIM bytes that were attached are returned. + /// + /// For [`CorimTemplate::LaunchMeasurement`], populated measurements + /// take their digest bytes from the launch measurement computed at + /// construction time (see [`new`](Self::new)). + /// + /// # Arguments + /// + /// * `platform` -- The target platform type. Must match a platform header + /// in the file, and must match the platform of the + /// [`LaunchMeasurement`](crate::corim::launch_measurement::LaunchMeasurement) + /// in the template. + /// * `template` -- The CoRIM template to instantiate. See + /// [`CorimTemplate`] for the supported variants. + #[cfg(feature = "corim")] + #[cfg_attr(docsrs, doc(cfg(feature = "corim")))] + pub fn add_corim( + &mut self, + platform: IgvmPlatformType, + template: CorimTemplate, + ) -> Result<&[u8], Error> { + let compatibility_mask = self.lookup_compatibility_mask(platform)?; + + let corim_bytes = match template { + CorimTemplate::LaunchMeasurement(le) => { + if le.platform() != platform { + return Err(Error::CorimGeneration(format!( + "LaunchMeasurement targets {:?} but add_corim was \ + called with {platform:?}", + le.platform() + ))); + } + + self.build_launch_measurement_corim(le)? + } + CorimTemplate::Architectural => { + return Err(Error::CorimGeneration( + "Architectural CoRIM template is not yet implemented".into(), + )); + } + CorimTemplate::Custom(_) => { + return Err(Error::CorimGeneration( + "Custom CoRIM template is not yet implemented".into(), + )); + } + }; + + self.extra_init_headers + .push(IgvmInitializationHeader::CorimDocument { + compatibility_mask, + document: corim_bytes, + }); + + match self.extra_init_headers.last() { + Some(IgvmInitializationHeader::CorimDocument { document, .. }) => Ok(document), + _ => unreachable!("just pushed a CorimDocument"), + } + } + + /// Resolve a [`LaunchMeasurement`]'s populated measurements and CES + /// triples into the internal builder form, then build the CoRIM bytes. + #[cfg(feature = "corim")] + fn build_launch_measurement_corim( + &self, + le: crate::corim::launch_measurement::LaunchMeasurement, + ) -> Result, Error> { + use crate::corim::launch_measurement::builder::ResolvedMeasurement; + use crate::corim::launch_measurement::measurement_info; + + let platform = le.platform(); + // The measurement was computed eagerly during `IgvmSerializer::new` + // for every measurable platform header in the file. The caller of + // `add_corim` already validated `le.platform() == platform`, and + // `LaunchMeasurement::for_platform` only succeeds for measurable + // platforms -- so the measurement must be present. + let cached = self.measurement_for(platform).ok_or_else(|| { + Error::CorimGeneration(format!("no platform header found for {platform:?}")) + })?; + + // The current launch-measurement builder emits exactly one + // reference value and one CES triple, all bound to the same + // measurement. Enforce that shape here rather than silently + // picking one entry from `measurement_kinds()` (a `HashSet` with + // unspecified iteration order). + if le.measurement_kinds().len() != 1 { + return Err(Error::CorimGeneration(format!( + "LaunchMeasurement requires exactly one populated measurement, got {}", + le.measurement_kinds().len() + ))); + } + let kind = *le + .measurement_kinds() + .iter() + .next() + .expect("checked len == 1 above"); + + if le.triples().len() != 1 { + return Err(Error::CorimGeneration(format!( + "LaunchMeasurement requires exactly one CES triple, got {}", + le.triples().len() + ))); + } + let triple = &le.triples()[0]; + + if triple.selected_measurements() != [kind] { + return Err(Error::CorimGeneration(format!( + "CES triple selection {:?} does not match the populated \ + measurement {kind:?}", + triple.selected_measurements() + ))); + } + + let (mkey, digest_alg, _len) = + measurement_info(platform, kind).expect("kind validated by set_measurement"); + let resolved = ResolvedMeasurement { + mkey: mkey.to_string(), + digest_alg, + digest: cached.digest.clone(), + }; + + crate::corim::launch_measurement::builder::build_corim_bytes( + platform, + &resolved, + triple.svn(), + ) + .map_err(|e| Error::CorimGeneration(e.to_string())) + } + + /// Serialize the IGVM file to binary format, including any CoRIM + /// documents that were added via [`add_corim`](Self::add_corim). + /// + /// This produces the same binary format as [`IgvmFile::serialize`], + /// but with additional initialization headers appended. + pub fn serialize(&self, output: &mut Vec) -> Result<(), Error> { + if self.extra_init_headers.is_empty() { + // Fast path: nothing added, delegate directly. + self.file.serialize(output) + } else { + // Clone the file and append the extra init headers so that + // the original IgvmFile::serialize handles all the work. + let mut file = self.file.clone(); + file.initializations_mut() + .extend(self.extra_init_headers.iter().cloned()); + file.serialize(output) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hv_defs::Vtl; + use crate::registers::X86Register; + use crate::Arch; + use crate::CorimTemplate; + use crate::IgvmInitializationHeader; + use crate::IgvmPlatformHeader; + use crate::IgvmRevision; + use igvm_defs::IgvmPageDataFlags; + use igvm_defs::IgvmPageDataType; + use igvm_defs::IgvmPlatformType; + use igvm_defs::IGVM_VHS_SUPPORTED_PLATFORM; + use igvm_defs::PAGE_SIZE_4K; + + fn new_platform(mask: u32, platform_type: IgvmPlatformType) -> IgvmPlatformHeader { + IgvmPlatformHeader::SupportedPlatform(IGVM_VHS_SUPPORTED_PLATFORM { + compatibility_mask: mask, + highest_vtl: 0, + platform_type, + platform_version: 1, + shared_gpa_boundary: 0, + }) + } + + fn new_page_data(page: u64, mask: u32, data: &[u8]) -> crate::IgvmDirectiveHeader { + crate::IgvmDirectiveHeader::PageData { + gpa: page * PAGE_SIZE_4K, + compatibility_mask: mask, + flags: IgvmPageDataFlags::new(), + data_type: IgvmPageDataType::NORMAL, + data: data.to_vec(), + } + } + + /// Build a minimal VBS IgvmFile with some page data and VP context. + fn make_vbs_file() -> IgvmFile { + IgvmFile::new( + IgvmRevision::V2 { + arch: Arch::X64, + page_size: PAGE_SIZE_4K as u32, + }, + vec![new_platform(0x1, IgvmPlatformType::VSM_ISOLATION)], + vec![], + vec![ + new_page_data(0, 1, &[0xAA; PAGE_SIZE_4K as usize]), + new_page_data(1, 1, &[0xBB; PAGE_SIZE_4K as usize]), + crate::IgvmDirectiveHeader::X64VbsVpContext { + vtl: Vtl::Vtl0, + registers: vec![X86Register::Rip(0x1000)], + compatibility_mask: 0x1, + }, + ], + ) + .unwrap() + } + + /// Build a minimal SNP IgvmFile with a guest policy and page data. + fn make_snp_file() -> IgvmFile { + IgvmFile::new( + IgvmRevision::V1, + vec![new_platform(0x1, IgvmPlatformType::SEV_SNP)], + vec![IgvmInitializationHeader::GuestPolicy { + policy: 0x30000, + compatibility_mask: 0x1, + }], + vec![ + new_page_data(0, 1, &[0xCC; PAGE_SIZE_4K as usize]), + new_page_data(1, 1, &[0xDD; PAGE_SIZE_4K as usize]), + ], + ) + .unwrap() + } + + /// Build a minimal TDX IgvmFile with page data. + fn make_tdx_file() -> IgvmFile { + IgvmFile::new( + IgvmRevision::V1, + vec![new_platform(0x1, IgvmPlatformType::TDX)], + vec![], + vec![ + new_page_data(0, 1, &[0xEE; PAGE_SIZE_4K as usize]), + new_page_data(1, 1, &[0xFF; PAGE_SIZE_4K as usize]), + ], + ) + .unwrap() + } + + // -- Basic serializer tests -------------------------------------- + + #[test] + fn serialize_without_corim_matches_file_serialize() { + let file = make_vbs_file(); + + // Serialize via IgvmFile::serialize + let mut direct = Vec::new(); + file.serialize(&mut direct).unwrap(); + + // Serialize via IgvmSerializer (no CoRIM added) + let serializer = IgvmSerializer::new(&file).unwrap(); + let mut via_builder = Vec::new(); + serializer.serialize(&mut via_builder).unwrap(); + + assert_eq!(direct, via_builder); + } + + #[test] + fn serialize_without_corim_roundtrips() { + let file = make_snp_file(); + + let serializer = IgvmSerializer::new(&file).unwrap(); + let mut output = Vec::new(); + serializer.serialize(&mut output).unwrap(); + + let deserialized = IgvmFile::new_from_binary(&output, None).unwrap(); + assert_eq!(file.platforms(), deserialized.platforms()); + assert_eq!(file.directives().len(), deserialized.directives().len()); + } + + // -- Measurement tests ------------------------------------------- + + #[test] + fn vbs_measurement_computed_eagerly() { + let file = make_vbs_file(); + let serializer = IgvmSerializer::new(&file).unwrap(); + + let m = serializer + .measurement_for(IgvmPlatformType::VSM_ISOLATION) + .expect("VBS measurement should be computed eagerly"); + assert_eq!(m.platform, IgvmPlatformType::VSM_ISOLATION); + assert_eq!(m.compatibility_mask, 0x1); + assert_eq!(m.digest.len(), 32); // SHA-256 + } + + #[test] + fn snp_measurement_computed_eagerly() { + let file = make_snp_file(); + let serializer = IgvmSerializer::new(&file).unwrap(); + + let m = serializer + .measurement_for(IgvmPlatformType::SEV_SNP) + .expect("SNP measurement should be computed eagerly"); + assert_eq!(m.platform, IgvmPlatformType::SEV_SNP); + assert_eq!(m.digest.len(), 48); // SHA-384 + } + + #[test] + fn tdx_measurement_computed_eagerly() { + let file = make_tdx_file(); + let serializer = IgvmSerializer::new(&file).unwrap(); + + let m = serializer + .measurement_for(IgvmPlatformType::TDX) + .expect("TDX measurement should be computed eagerly"); + assert_eq!(m.platform, IgvmPlatformType::TDX); + assert_eq!(m.digest.len(), 48); // SHA-384 + } + + #[test] + fn measurement_for_returns_none_for_absent_platform() { + // File has SNP only; querying TDX should return None. + let file = make_snp_file(); + let serializer = IgvmSerializer::new(&file).unwrap(); + assert!(serializer.measurement_for(IgvmPlatformType::TDX).is_none()); + } + + #[test] + fn unmeasurable_platform_skipped() { + // NATIVE has no measurement profile in this crate. A file containing + // only a NATIVE platform header should construct cleanly with an + // empty measurements list. + let file = IgvmFile::new( + IgvmRevision::V1, + vec![new_platform(0x1, IgvmPlatformType::NATIVE)], + vec![], + vec![new_page_data(0, 1, &[0xAA; PAGE_SIZE_4K as usize])], + ) + .unwrap(); + let serializer = IgvmSerializer::new(&file).unwrap(); + assert!(serializer.measurements().is_empty()); + assert!(serializer + .measurement_for(IgvmPlatformType::NATIVE) + .is_none()); + } + + #[test] + fn snp_missing_guest_policy_fails_construction() { + // SNP measurement requires a GuestPolicy initialization header. + // Build a file without one and verify `IgvmSerializer::new` fails. + let file = IgvmFile::new( + IgvmRevision::V1, + vec![new_platform(0x1, IgvmPlatformType::SEV_SNP)], + vec![], // no GuestPolicy + vec![new_page_data(0, 1, &[0xAA; PAGE_SIZE_4K as usize])], + ) + .unwrap(); + let err = IgvmSerializer::new(&file).unwrap_err(); + assert!(matches!(err, Error::MeasurementFailed(_)), "got: {err:?}"); + } + + // -- CoRIM integration tests ------------------------------------- + + /// Helper: build a `CorimTemplate::LaunchMeasurement` with a single + /// CES triple binding the launch measurement -> svn. + fn launch_measurement_template(platform: IgvmPlatformType, svn: u64) -> CorimTemplate { + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let mut le = LaunchMeasurement::for_platform(platform).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + le.endorse(svn) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + le.build() + } + + #[test] + fn add_corim_produces_larger_output() { + let file = make_snp_file(); + + // Serialize without CoRIM + let mut without = Vec::new(); + file.serialize(&mut without).unwrap(); + + // Serialize with CoRIM + let mut serializer = IgvmSerializer::new(&file).unwrap(); + serializer + .add_corim( + IgvmPlatformType::SEV_SNP, + launch_measurement_template(IgvmPlatformType::SEV_SNP, 1), + ) + .unwrap(); + let mut with = Vec::new(); + serializer.serialize(&mut with).unwrap(); + + // Output with CoRIM should be larger (has the CorimDocument init header) + assert!(with.len() > without.len()); + } + + #[test] + fn add_corim_uses_eager_measurement() { + let file = make_tdx_file(); + let mut serializer = IgvmSerializer::new(&file).unwrap(); + + // Measurement was computed eagerly during construction. + assert!(serializer.measurement_for(IgvmPlatformType::TDX).is_some()); + assert_eq!(serializer.measurements().len(), 1); + + // add_corim should reuse the cached measurement. + serializer + .add_corim( + IgvmPlatformType::TDX, + launch_measurement_template(IgvmPlatformType::TDX, 5), + ) + .unwrap(); + + assert_eq!(serializer.measurements().len(), 1); + } + + #[test] + fn add_corim_output_roundtrips() { + let file = make_snp_file(); + let mut serializer = IgvmSerializer::new(&file).unwrap(); + serializer + .add_corim( + IgvmPlatformType::SEV_SNP, + launch_measurement_template(IgvmPlatformType::SEV_SNP, 42), + ) + .unwrap(); + + let mut output = Vec::new(); + serializer.serialize(&mut output).unwrap(); + + // Should parse back successfully and contain a CorimDocument + let deserialized = IgvmFile::new_from_binary(&output, None).unwrap(); + let has_corim = deserialized + .initializations() + .iter() + .any(|h| matches!(h, IgvmInitializationHeader::CorimDocument { .. })); + assert!(has_corim); + } + + #[test] + fn file_not_mutated_after_add_corim() { + let file = make_snp_file(); + let init_count_before = file.initializations().len(); + + let mut serializer = IgvmSerializer::new(&file).unwrap(); + serializer + .add_corim( + IgvmPlatformType::SEV_SNP, + launch_measurement_template(IgvmPlatformType::SEV_SNP, 1), + ) + .unwrap(); + + // The original file should not have been mutated + assert_eq!(file.initializations().len(), init_count_before); + } + + // -- Two-stage builder tests ------------------------------------- + + #[test] + fn launch_measurement_unsupported_platform() { + use crate::corim::launch_measurement::Error as LeError; + use crate::corim::launch_measurement::LaunchMeasurement; + let err = LaunchMeasurement::for_platform(IgvmPlatformType::NATIVE).unwrap_err(); + assert!(matches!(err, LeError::UnsupportedPlatform(_))); + } + + #[test] + fn launch_measurement_select_unpopulated_rejected() { + use crate::corim::launch_measurement::Error as LeError; + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + let err = le.endorse(1).with(MeasurementKind::Launch).unwrap_err(); + assert!(matches!(err, LeError::MeasurementNotPopulated { .. })); + } + + #[test] + fn launch_measurement_duplicate_selection_rejected() { + use crate::corim::launch_measurement::Error as LeError; + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + let err = le + .endorse(1) + .with(MeasurementKind::Launch) + .unwrap() + .with(MeasurementKind::Launch) + .unwrap_err(); + assert!(matches!(err, LeError::DuplicateSelection { .. })); + } + + #[test] + fn launch_measurement_empty_selection_rejected() { + use crate::corim::launch_measurement::Error as LeError; + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + let err = le.endorse(1).finish().unwrap_err(); + assert!(matches!(err, LeError::EmptySelection)); + } + + #[test] + fn add_corim_platform_mismatch_rejected() { + let file = make_snp_file(); + let mut serializer = IgvmSerializer::new(&file).unwrap(); + // LaunchMeasurement targets TDX but we call add_corim with SEV_SNP. + let template = launch_measurement_template(IgvmPlatformType::TDX, 1); + let err = serializer + .add_corim(IgvmPlatformType::SEV_SNP, template) + .unwrap_err(); + assert!(err.to_string().contains("targets")); + } + + #[test] + fn add_corim_no_ces_triple_rejected() { + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let file = make_snp_file(); + let mut serializer = IgvmSerializer::new(&file).unwrap(); + + // Populate a measurement but never call `endorse(...).finish()`. + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + + let err = serializer + .add_corim(IgvmPlatformType::SEV_SNP, le.build()) + .unwrap_err(); + assert!(err.to_string().contains("got 0"), "got: {err}"); + } + + #[test] + fn add_corim_multiple_ces_triples_rejected() { + use crate::corim::launch_measurement::LaunchMeasurement; + use crate::corim::launch_measurement::MeasurementKind; + let file = make_snp_file(); + let mut serializer = IgvmSerializer::new(&file).unwrap(); + + let mut le = LaunchMeasurement::for_platform(IgvmPlatformType::SEV_SNP).unwrap(); + le.set_measurement(MeasurementKind::Launch).unwrap(); + // Two CES triples in one endorsement. + le.endorse(1) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + le.endorse(2) + .with(MeasurementKind::Launch) + .unwrap() + .finish() + .unwrap(); + + let err = serializer + .add_corim(IgvmPlatformType::SEV_SNP, le.build()) + .unwrap_err(); + assert!(err.to_string().contains("got 2"), "got: {err}"); + } + + #[test] + fn measurement_deterministic() { + let file = make_vbs_file(); + + let s1 = IgvmSerializer::new(&file).unwrap(); + let m1 = s1 + .measurement_for(IgvmPlatformType::VSM_ISOLATION) + .unwrap() + .digest + .clone(); + + let s2 = IgvmSerializer::new(&file).unwrap(); + let m2 = s2 + .measurement_for(IgvmPlatformType::VSM_ISOLATION) + .unwrap() + .digest + .clone(); + + assert_eq!(m1, m2); + } +}