use alloc::vec::Vec; pub use attestation::attestation::{ AcceptedDstackAttestation, DstackAttestation, VerificationError, }; pub use attestation::measurements::{ExpectedMeasurements, Measurements}; use attestation::{ app_compose::AppCompose, attestation::{GetSingleEvent as _, OrErr as _}, report_data::ReportData, }; use include_measurements::include_measurements; use mpc_primitives::hash::{LauncherDockerComposeHash, NodeImageHash}; use tee_verifier_interface::VerifiedReport; use borsh::{BorshDeserialize, BorshSerialize}; use launcher_interface::MPC_IMAGE_HASH_EVENT; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use crate::alloc::format; use crate::alloc::string::{String, ToString}; /// How long an accepted attestation stays trusted before it must be /// re-verified via [`VerifiedAttestation::re_verify`]. Nodes resubmit hourly, /// well within this window, so valid attestations refresh in time. // TODO(#1639): extract timestamp from certificate itself pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24; // 1 day #[expect(clippy::large_enum_variant)] #[derive(Clone, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub enum Attestation { Dstack(DstackAttestation), Mock(MockAttestation), } #[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) )] pub enum VerifiedAttestation { Dstack(ValidatedDstackAttestation), Mock(MockAttestation), } /// Result of successfully verifying an attestation. #[derive(Clone, Debug)] pub struct AcceptedAttestation { /// The validated attestation data extracted during verification, stored for /// later re-verification against the then-current allowed set. pub attestation: VerifiedAttestation, /// Informational advisory IDs (e.g. `INTEL-DOC-10000` post-ESU) surfaced by /// Intel's PCS alongside an `UpToDate` TCB status. They are not a security /// failure — `UpToDate` is the sole security gate; these advisories convey /// platform lifecycle information. pub advisory_ids: Vec, } impl AcceptedAttestation { /// Assembles the acceptance for a verified `Dstack` attestation, stamping the /// expiry. fn dstack( mpc_image_hash: NodeImageHash, launcher_compose_hash: LauncherDockerComposeHash, measurements: ExpectedMeasurements, advisory_ids: Vec, current_timestamp_seconds: u64, ) -> Self { // TODO(#1639): extract timestamp from certificate itself let expiration_timestamp_seconds = current_timestamp_seconds + DEFAULT_EXPIRATION_DURATION_SECONDS; Self { attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { mpc_image_hash, launcher_compose_hash, expiry_timestamp_seconds: expiration_timestamp_seconds, measurements, }), advisory_ids, } } /// Assembles the acceptance for a verified `Mock` attestation. fn mock(mock_attestation: &MockAttestation) -> Self { Self { attestation: VerifiedAttestation::Mock(mock_attestation.clone()), advisory_ids: Vec::new(), } } } #[expect(clippy::large_enum_variant)] #[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) )] pub enum MockAttestation { #[default] /// Always pass validation Valid, /// Always fails validation Invalid, /// Pass validation depending on the set constraints WithConstraints { mpc_docker_image_hash: Option, launcher_docker_compose_hash: Option, /// Unix time stamp for when this attestation expires. expiry_timestamp_seconds: Option, expected_measurements: Option, }, } impl MockAttestation { pub fn verify( &self, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { self.verify_constraints( current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, )?; Ok(AcceptedAttestation::mock(self)) } /// Checks the mock's constraints, returning only pass/fail. Lets the /// re-verification path validate without building an [`AcceptedAttestation`]. fn verify_constraints( &self, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result<(), VerificationError> { match self { MockAttestation::Valid => Ok(()), MockAttestation::Invalid => Err(VerificationError::InvalidMockAttestation), MockAttestation::WithConstraints { mpc_docker_image_hash, launcher_docker_compose_hash, expiry_timestamp_seconds, expected_measurements, } => { if let Some(hash) = mpc_docker_image_hash { if allowed_mpc_docker_image_hashes.is_empty() { return Err(VerificationError::Custom( "the allowed mpc image hashes list is empty".to_string(), )); } allowed_mpc_docker_image_hashes.contains(hash).or_err(|| { VerificationError::Custom(format!( "MPC image hash {} is not in the allowed hashes list", hex::encode(hash.as_ref(),) )) })?; }; if let Some(hash) = launcher_docker_compose_hash { if allowed_launcher_docker_compose_hashes.is_empty() { return Err(VerificationError::Custom( "the allowed mpc launcher compose hashes list is empty".to_string(), )); } allowed_launcher_docker_compose_hashes .contains(hash) .or_err(|| { VerificationError::Custom(format!( "launcher compose hash {} is not in the allowed hashes list", hex::encode(hash.as_ref(),) )) })?; }; if let Some(expiry_timestamp) = expiry_timestamp_seconds { (current_timestamp_seconds < *expiry_timestamp).or_err(|| { VerificationError::ExpiredCertificate { attestation_time: current_timestamp_seconds, expiry_time: *expiry_timestamp, } })?; }; if let Some(measurements) = expected_measurements { verify_measurements(measurements, accepted_measurements)?; } Ok(()) } } } } #[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) )] pub struct ValidatedDstackAttestation { pub mpc_image_hash: NodeImageHash, pub launcher_compose_hash: LauncherDockerComposeHash, // TODO(#1639): This timestamp can not come from the contract, // but should be extracted from the certificate itself. pub expiry_timestamp_seconds: u64, /// The measurements that were verified during initial attestation. /// Stored so that re-verification can check they are still in the allowed set. pub measurements: ExpectedMeasurements, } impl VerifiedAttestation { pub fn re_verify( &self, timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], allowed_measurements: &[ExpectedMeasurements], ) -> Result<(), VerificationError> { match self { Self::Dstack(ValidatedDstackAttestation { mpc_image_hash, launcher_compose_hash, expiry_timestamp_seconds: expiration_timestamp_seconds, measurements, }) => { let attestation_has_expired = *expiration_timestamp_seconds < timestamp_seconds; if attestation_has_expired { return Err(VerificationError::Custom(format!( "The attestation expired at t = {:?}, time_now = {:?}", expiration_timestamp_seconds, timestamp_seconds ))); } let () = verify_mpc_hash(mpc_image_hash, allowed_mpc_docker_image_hashes)?; let () = verify_launcher_compose_hash( launcher_compose_hash, allowed_launcher_docker_compose_hashes, )?; verify_measurements(measurements, allowed_measurements)?; Ok(()) } Self::Mock(mock_attestation) => mock_attestation.verify_constraints( timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, allowed_measurements, ), } } } /// Returns the default compiled-in TCB measurements (prod + dev). pub fn default_measurements() -> &'static [ExpectedMeasurements] { static MEASUREMENTS: [ExpectedMeasurements; 2] = [ include_measurements!("assets/tcb_info.json"), // TODO(#1433): Security - remove dev measurements from production builds after testing is complete include_measurements!("assets/tcb_info_dev.json"), ]; &MEASUREMENTS } /// Verification for a [`DstackAttestation`] at the `mpc-attestation` layer. /// /// [`DstackAttestation`] is defined in the lower `attestation` crate, which knows /// nothing of `mpc-primitives` hashes, so the MPC image / launcher compose checks /// (and the resulting [`AcceptedAttestation`]) live here as an extension trait /// rather than an inherent method. Mirrors [`MockAttestation::verify`]. pub trait DstackVerify { /// Runs the MPC-hash allowlist checks and the post-DCAP checks against an /// already-DCAP-verified [`VerifiedReport`], returning the /// [`AcceptedAttestation`]. fn verify( &self, report: &VerifiedReport, expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result; } impl DstackVerify for DstackAttestation { fn verify( &self, report: &VerifiedReport, expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { let (mpc_image_hash, launcher_compose_hash) = verify_dstack_mpc_hashes( self, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, )?; let AcceptedDstackAttestation { measurements, advisory_ids, } = self.verify_with_report(report, expected_report_data, accepted_measurements)?; Ok(AcceptedAttestation::dstack( mpc_image_hash, launcher_compose_hash, measurements, advisory_ids, current_timestamp_seconds, )) } } impl Attestation { /// Verifies the attestation given an already-DCAP-verified report. pub fn verify_with_report( &self, report: &VerifiedReport, expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { match self { Self::Dstack(dstack_attestation) => dstack_attestation.verify( report, expected_report_data, current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, ), Self::Mock(mock_attestation) => mock_attestation.verify( current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, ), } } /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then /// the post-DCAP checks. Behind the `local-verify` feature, which pulls in /// `dcap-qvl`. Used by off-chain callers and, today, by `mpc-contract`. // TODO(#3264): contract drops this once DCAP moves to the verifier contract. #[cfg(feature = "local-verify")] pub fn verify_locally( &self, expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { match self { Self::Dstack(dstack_attestation) => { let report = dstack_attestation.verify_dcap_quote(current_timestamp_seconds)?; dstack_attestation.verify( &report, expected_report_data, current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, ) } Self::Mock(mock_attestation) => mock_attestation.verify( current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, ), } } } /// Derives the MPC image hash (from the [`MPC_IMAGE_HASH_EVENT`] TCB-info event) /// and launcher compose hash (SHA-256 of the app-compose `docker_compose_file`), /// checks them against `allowed_mpc_docker_image_hashes` and /// `allowed_launcher_docker_compose_hashes` respectively, and returns the pair. fn verify_dstack_mpc_hashes( dstack_attestation: &DstackAttestation, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], ) -> Result<(NodeImageHash, LauncherDockerComposeHash), VerificationError> { let mpc_image_hash: NodeImageHash = { let mpc_image_hash_payload = &dstack_attestation .tcb_info .get_single_event(MPC_IMAGE_HASH_EVENT)? .event_payload; // TODO(#2478): decode raw bytes let mpc_image_hash_bytes: Vec = hex::decode(mpc_image_hash_payload).map_err(|err| { VerificationError::Custom(format!("provided mpc image is not hex encoded: {:?}", err)) })?; let mpc_image_hash_bytes: [u8; 32] = mpc_image_hash_bytes.try_into().map_err(|_| { VerificationError::Custom("The provided MPC image hash is not 32 bytes".to_string()) })?; NodeImageHash::from(mpc_image_hash_bytes) }; let () = verify_mpc_hash(&mpc_image_hash, allowed_mpc_docker_image_hashes)?; let launcher_compose_hash: LauncherDockerComposeHash = { let app_compose: AppCompose = serde_json::from_str(&dstack_attestation.tcb_info.app_compose) .map_err(|e| VerificationError::AppComposeParsing(e.to_string()))?; let launcher_compose_hash_bytes: [u8; 32] = Sha256::digest(app_compose.docker_compose_file.as_bytes()).into(); LauncherDockerComposeHash::from(launcher_compose_hash_bytes) }; let () = verify_launcher_compose_hash( &launcher_compose_hash, allowed_launcher_docker_compose_hashes, )?; Ok((mpc_image_hash, launcher_compose_hash)) } /// Verifies MPC node image hash is in allowed list. fn verify_mpc_hash( image_hash: &NodeImageHash, allowed_hashes: &[NodeImageHash], ) -> Result<(), VerificationError> { if allowed_hashes.is_empty() { return Err(VerificationError::Custom( "the allowed mpc image hashes list is empty".to_string(), )); } let image_hash_is_allowed = allowed_hashes.contains(image_hash); if !image_hash_is_allowed { return Err(VerificationError::Custom(format!( "MPC image hash {:?} is not in the allowed hashes list", image_hash ))); } Ok(()) } fn verify_launcher_compose_hash( launcher_compose_hash: &LauncherDockerComposeHash, allowed_hashes: &[LauncherDockerComposeHash], ) -> Result<(), VerificationError> { if allowed_hashes.is_empty() { return Err(VerificationError::Custom( "the allowed mpc launcher compose hashes list is empty".to_string(), )); } let launcher_compose_hash_is_allowed = allowed_hashes.contains(launcher_compose_hash); if !launcher_compose_hash_is_allowed { return Err(VerificationError::Custom(format!( "MPC launcher compose hash {:?} is not in the allowed hashes list", launcher_compose_hash ))); } Ok(()) } fn verify_measurements( measurements: &ExpectedMeasurements, allowed_measurements: &[ExpectedMeasurements], ) -> Result<(), VerificationError> { if allowed_measurements.is_empty() { return Err(VerificationError::EmptyMeasurementsList); } if !allowed_measurements.contains(measurements) { return Err(VerificationError::MeasurementsNotAllowed); } Ok(()) } #[cfg(test)] mod tests { use alloc::vec; use super::*; #[test] fn mock_constrained_verification_passes_if_hash_in_allowed_list() { let allowed_hash = NodeImageHash::from([42; 32]); let hash_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: Some(allowed_hash), launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: None, }); let other_hash = NodeImageHash::from([1; 32]); let allowed_mpc_hashes: Vec = vec![other_hash, allowed_hash]; hash_constrained_attestation .re_verify(0, &allowed_mpc_hashes, &[], &[]) .expect("constrained mpc image hash is allowed and should therefore pass validation"); } #[test] fn mock_constrained_verification_fails_if_hash_not_in_allowed_list() { let restricted_hash = NodeImageHash::from([42; 32]); let hash_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: Some(restricted_hash), launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: None, }); let other_hash = NodeImageHash::from([1; 32]); let allowed_mpc_hashes: Vec = vec![other_hash]; let result = hash_constrained_attestation.re_verify(0, &allowed_mpc_hashes, &[], &[]); match result { Err(VerificationError::Custom(msg)) => { assert!( msg.contains("MPC image hash"), "Expected error message regarding MPC image hash, got: {}", msg ); } _ => panic!("Expected Custom VerificationError, got: {:?}", result), } } #[test] fn mock_constrained_verification_fails_if_allowed_list_is_empty() { let restricted_hash = NodeImageHash::from([42; 32]); let hash_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: Some(restricted_hash), launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: None, }); let allowed_mpc_hashes: Vec = vec![]; let result = hash_constrained_attestation.re_verify(0, &allowed_mpc_hashes, &[], &[]); match result { Err(VerificationError::Custom(msg)) => { assert!(msg.contains("list is empty")); } _ => panic!("Expected empty list error, got: {:?}", result), } } #[test] fn launcher_constraint_passes_if_hash_in_allowed_list() { let allowed_hash = LauncherDockerComposeHash::from([99; 32]); let hash_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: Some(allowed_hash), expiry_timestamp_seconds: None, expected_measurements: None, }); let other_hash = LauncherDockerComposeHash::from([1; 32]); let allowed_launcher_hashes: Vec = vec![other_hash, allowed_hash]; hash_constrained_attestation .re_verify(0, &[], &allowed_launcher_hashes, &[]) .expect("constrained launcher hash is allowed and should therefore pass validation"); } #[test] fn launcher_constraint_fails_if_hash_not_in_allowed_list() { let restricted_hash = LauncherDockerComposeHash::from([99; 32]); let hash_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: Some(restricted_hash), expiry_timestamp_seconds: None, expected_measurements: None, }); let other_hash = LauncherDockerComposeHash::from([1; 32]); let allowed_launcher_hashes: Vec = vec![other_hash]; let result = hash_constrained_attestation.re_verify(0, &[], &allowed_launcher_hashes, &[]); match result { Err(VerificationError::Custom(msg)) => { assert!(msg.contains("launcher compose hash")); } _ => panic!("Expected Custom VerificationError, got: {:?}", result), } } #[test] fn mock_time_constraint_passes_if_time_is_within_expiry_window() { let expiry_timestamp_seconds = 101; let time_now = 100; let time_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_timestamp_seconds), expected_measurements: None, }); time_constrained_attestation .re_verify(time_now, &[], &[], &[]) .expect("Attestation is within valid time window and should pass"); } #[test] fn time_constraint_fails_if_time_is_past_expiry_window() { let expiry_timestamp_seconds = 100; let time_now = 101; let time_constrained_attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_timestamp_seconds), expected_measurements: None, }); let verification_result = time_constrained_attestation.re_verify(time_now, &[], &[], &[]); assert_matches::assert_matches!( verification_result, Err(VerificationError::ExpiredCertificate { attestation_time, expiry_time, }) if attestation_time == time_now && expiry_time == expiry_timestamp_seconds ); } fn make_measurements(byte: u8) -> ExpectedMeasurements { ExpectedMeasurements { rtmrs: Measurements { mrtd: [byte; 48], rtmr0: [byte; 48], rtmr1: [byte; 48], rtmr2: [byte; 48], }, key_provider_event_digest: [byte; 48], } } #[test] fn measurements_constraint_passes_if_in_allowed_list() { // given let measurements = make_measurements(42); let attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: Some(measurements), }); let allowed = vec![make_measurements(1), measurements]; // when/then attestation .re_verify(0, &[], &[], &allowed) .expect("measurements are in the allowed list and should pass"); } #[test] fn measurements_constraint_fails_if_not_in_allowed_list() { // given let measurements = make_measurements(42); let attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: Some(measurements), }); let allowed = vec![make_measurements(1)]; // when let result = attestation.re_verify(0, &[], &[], &allowed); // then assert_matches::assert_matches!(result, Err(VerificationError::MeasurementsNotAllowed)); } #[test] fn measurements_constraint_fails_if_allowed_list_is_empty() { // given let measurements = make_measurements(42); let attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: Some(measurements), }); // when let result = attestation.re_verify(0, &[], &[], &[]); // then assert_matches::assert_matches!(result, Err(VerificationError::EmptyMeasurementsList)); } #[test] fn no_measurements_constraint_skips_check() { // given let attestation = VerifiedAttestation::Mock(MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: None, expected_measurements: None, }); // when/then attestation .re_verify(0, &[], &[], &[]) .expect("no measurements constraint should skip the check"); } }