diff --git a/Cargo.lock b/Cargo.lock index 329e443418..12c536d45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,7 @@ name = "attestation" version = "3.12.0" dependencies = [ "assert_matches", + "attestation", "borsh", "dcap-qvl", "derive_more 2.1.1", @@ -986,6 +987,8 @@ dependencies = [ "serde_json", "serde_with", "sha2 0.10.9", + "tee-verifier-conversions", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", ] @@ -5735,11 +5738,13 @@ dependencies = [ "hex", "include-measurements", "launcher-interface", + "mpc-attestation", "mpc-primitives", "serde", "serde_json", "sha2 0.10.9", "sha3", + "tee-verifier-interface", "test-utils", ] @@ -11093,12 +11098,22 @@ dependencies = [ "getrandom 0.2.17", "hex", "near-sdk", - "rstest", "tee-verifier", + "tee-verifier-conversions", "tee-verifier-interface", "test-utils", ] +[[package]] +name = "tee-verifier-conversions" +version = "3.12.0" +dependencies = [ + "borsh", + "dcap-qvl", + "rstest", + "tee-verifier-interface", +] + [[package]] name = "tee-verifier-interface" version = "3.12.0" @@ -11106,6 +11121,7 @@ dependencies = [ "borsh", "derive_more 2.1.1", "rstest", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1ed36c1c03..4d05a5d696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/tee-context", "crates/tee-launcher", "crates/tee-verifier", + "crates/tee-verifier-conversions", "crates/tee-verifier-interface", "crates/test-migration-contract", "crates/test-parallel-contract", @@ -67,6 +68,7 @@ near-mpc-sdk = { path = "crates/near-mpc-sdk", version = "0.0.1" } near-mpc-signature-verifier = { path = "crates/near-mpc-signature-verifier", version = "0.0.1" } node-types = { path = "crates/node-types" } tee-authority = { path = "crates/tee-authority" } +tee-verifier-conversions = { path = "crates/tee-verifier-conversions" } tee-verifier-interface = { path = "crates/tee-verifier-interface" } test-port-allocator = { path = "crates/test-port-allocator" } test-utils = { path = "crates/test-utils" } diff --git a/crates/attestation-cli/Cargo.toml b/crates/attestation-cli/Cargo.toml index e861aa8ca7..341671ba42 100644 --- a/crates/attestation-cli/Cargo.toml +++ b/crates/attestation-cli/Cargo.toml @@ -13,7 +13,7 @@ anyhow = { workspace = true } attestation = { workspace = true } bs58 = { workspace = true } clap = { workspace = true } -mpc-attestation = { workspace = true } +mpc-attestation = { workspace = true, features = ["local-verify"] } mpc-primitives = { workspace = true } node-types = { workspace = true } reqwest = { workspace = true } diff --git a/crates/attestation-cli/src/verify.rs b/crates/attestation-cli/src/verify.rs index bb25944d31..5491193f0d 100644 --- a/crates/attestation-cli/src/verify.rs +++ b/crates/attestation-cli/src/verify.rs @@ -74,7 +74,7 @@ pub fn verify_at_timestamp( let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify( + } = attestation.verify_locally( report_data.into(), timestamp_seconds, &cli.allowed_image_hashes, diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index 5fc292a911..ee6931d59f 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -5,13 +5,17 @@ license = { workspace = true } edition = { workspace = true } [features] -borsh-schema = ["borsh/unstable__schema"] +borsh-schema = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] dstack-conversions = ["dep:dstack-sdk-types"] test-utils = [] +# Pulls in `dcap-qvl` for full local DCAP + post-DCAP verification. Meant for +# off-chain callers; `mpc-contract` enables it today. +# TODO(#3264): contract drops this once DCAP moves to the verifier contract. +local-verify = ["dep:dcap-qvl", "dep:tee-verifier-conversions"] [dependencies] borsh = { workspace = true } -dcap-qvl = { workspace = true } +dcap-qvl = { workspace = true, optional = true } derive_more = { workspace = true } dstack-sdk-types = { workspace = true, optional = true } hex = { workspace = true } @@ -19,10 +23,17 @@ serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } sha2 = { workspace = true } +tee-verifier-conversions = { workspace = true, optional = true } +tee-verifier-interface = { workspace = true, features = ["serde"] } thiserror = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } +attestation = { path = ".", features = [ + "local-verify", + "test-utils", + "dstack-conversions", +] } dstack-sdk-types = { workspace = true } rstest = { workspace = true } test-utils = { workspace = true } diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 39f5a52805..457cc14ab8 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -17,6 +17,10 @@ use core::fmt; use derive_more::Constructor; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256, Sha384}; +use tee_verifier_interface::{TDReport10, VerifiedReport}; + +#[cfg(feature = "local-verify")] +use crate::dcap_conversions::{IntoDcapType as _, IntoInterfaceType as _}; /// Expected TCB status for a successfully verified TEE quote. const EXPECTED_QUOTE_STATUS: &str = "UpToDate"; @@ -37,9 +41,10 @@ pub struct DstackAttestation { pub tcb_info: TcbInfo, } -/// Result of a successful [`DstackAttestation::verify`] call. +/// Result of successfully verifying an attestation. #[derive(Clone, Debug)] pub struct AcceptedDstackAttestation { + /// The accepted measurement set this attestation matched. pub measurements: ExpectedMeasurements, /// 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 @@ -123,33 +128,20 @@ impl fmt::Debug for DstackAttestation { } impl DstackAttestation { - /// Checks whether this attestation is valid - /// with respect to expected values of: - /// - report_data: must be measured correctly in RTMR3 - /// - timestamp_seconds: current UNIX time in seconds - /// - accepted_measurements: set of accepted RTMRs and key-provider event digest. - /// If any element in the set is valid, the function accepts the attestation as - /// valid. - /// - /// On success, returns the matched measurements along with any informational - /// advisory IDs surfaced alongside an `UpToDate` TCB status. - pub fn verify( + /// Runs the post-DCAP checks against an already-verified report. + pub fn verify_with_report( &self, + report: &VerifiedReport, expected_report_data: ReportData, - timestamp_seconds: u64, accepted_measurements: &[ExpectedMeasurements], ) -> Result { - let verification_result = - dcap_qvl::verify::verify(&self.quote, &self.collateral, timestamp_seconds) - .map_err(|e| VerificationError::DcapVerification(e.to_string()))?; - - let report_data = verification_result + let report_data = report .report .as_td10() .ok_or(VerificationError::ReportNotTd10)?; // Verify all attestation components - let advisory_ids = Self::verify_tcb_status(&verification_result)?; + let advisory_ids = Self::verify_tcb_status(report)?; self.verify_report_data(&expected_report_data, report_data)?; self.verify_rtmr3(report_data, &self.tcb_info)?; @@ -163,6 +155,35 @@ impl DstackAttestation { }) } + /// Full local verification: runs `dcap_qvl::verify::verify` and then the + /// post-DCAP checks via [`Self::verify_with_report`]. + #[cfg(feature = "local-verify")] + pub fn verify_locally( + &self, + expected_report_data: ReportData, + timestamp_seconds: u64, + accepted_measurements: &[ExpectedMeasurements], + ) -> Result { + let report = self.verify_dcap_quote(timestamp_seconds)?; + self.verify_with_report(&report, expected_report_data, accepted_measurements) + } + + /// Runs only the DCAP step (`dcap_qvl::verify::verify`) and returns the + /// resulting report as the `tee-verifier-interface` mirror — the same value + /// the `tee-verifier` contract returns on-chain. + #[cfg(feature = "local-verify")] + pub fn verify_dcap_quote( + &self, + timestamp_seconds: u64, + ) -> Result { + let collateral = self.collateral.clone().into_dcap_type(); + Ok( + dcap_qvl::verify::verify(&self.quote.0, &collateral, timestamp_seconds) + .map_err(|e| VerificationError::DcapVerification(e.to_string()))? + .into_interface_type(), + ) + } + /// Replays RTMR3 from the event log by hashing all relevant events together and verifies all /// digests are correct fn verify_event_log_rtmr3( @@ -241,21 +262,18 @@ impl DstackAttestation { /// after a product's Extended Servicing Updates date). These may appear with /// `UpToDate` and do not indicate a vulnerability; they are returned so the /// caller can log/expose them. - fn verify_tcb_status( - verification_result: &dcap_qvl::verify::VerifiedReport, - ) -> Result, VerificationError> { - (verification_result.status == EXPECTED_QUOTE_STATUS).or_err(|| { - VerificationError::TcbStatusNotUpToDate(verification_result.status.clone()) - })?; + fn verify_tcb_status(report: &VerifiedReport) -> Result, VerificationError> { + (report.status == EXPECTED_QUOTE_STATUS) + .or_err(|| VerificationError::TcbStatusNotUpToDate(report.status.clone()))?; - Ok(verification_result.advisory_ids.clone()) + Ok(report.advisory_ids.clone()) } /// Verifies report data matches expected values. fn verify_report_data( &self, expected: &ReportData, - actual: &dcap_qvl::quote::TDReport10, + actual: &TDReport10, ) -> Result<(), VerificationError> { // Check if sha384(tls_public_key) matches the hash in report_data. This check effectively // proves that tls_public_key was included in the quote's report_data by an app running @@ -267,7 +285,7 @@ impl DstackAttestation { /// On success, returns the matched measurements. fn verify_any_measurements( &self, - report_data: &dcap_qvl::quote::TDReport10, + report_data: &TDReport10, tcb_info: &TcbInfo, accepted_measurements: &[ExpectedMeasurements], ) -> Result { @@ -292,7 +310,7 @@ impl DstackAttestation { /// Verifies static RTMRs match expected values. fn verify_static_rtmrs( &self, - report_data: &dcap_qvl::quote::TDReport10, + report_data: &TDReport10, tcb_info: &TcbInfo, expected_measurements: &ExpectedMeasurements, ) -> Result<(), VerificationError> { @@ -346,7 +364,7 @@ impl DstackAttestation { /// Verifies RTMR3 by replaying event log. fn verify_rtmr3( &self, - report_data: &dcap_qvl::quote::TDReport10, + report_data: &TDReport10, tcb_info: &TcbInfo, ) -> Result<(), VerificationError> { compare_hashes("rtmr3", tcb_info.rtmr3.as_slice(), &report_data.rt_mr3)?; @@ -489,10 +507,8 @@ mod tests { use super::*; use alloc::{string::ToString, vec, vec::Vec}; - use dcap_qvl::{ - quote::{EnclaveReport, Report}, - tcb_info::{TcbStatus, TcbStatusWithAdvisory}, - verify::VerifiedReport, + use tee_verifier_interface::{ + EnclaveReport, Report, TcbStatus, TcbStatusWithAdvisory, VerifiedReport, }; fn verified_report(status: &str, advisory_ids: Vec) -> VerifiedReport { @@ -516,8 +532,14 @@ mod tests { report_data: [0u8; 64], }), ppid: Vec::new(), - qe_status: TcbStatusWithAdvisory::new(TcbStatus::UpToDate, Vec::new()), - platform_status: TcbStatusWithAdvisory::new(TcbStatus::UpToDate, Vec::new()), + qe_status: TcbStatusWithAdvisory { + status: TcbStatus::UpToDate, + advisory_ids: Vec::new(), + }, + platform_status: TcbStatusWithAdvisory { + status: TcbStatus::UpToDate, + advisory_ids: Vec::new(), + }, } } diff --git a/crates/attestation/src/collateral.rs b/crates/attestation/src/collateral.rs index 48514d9bcf..0a39b97ef5 100644 --- a/crates/attestation/src/collateral.rs +++ b/crates/attestation/src/collateral.rs @@ -1,37 +1,26 @@ -use borsh::{BorshDeserialize, BorshSerialize}; -use derive_more::{Deref, From, Into}; -use serde::{Deserialize, Serialize}; +//! Quote collateral (Intel certificates + TCB info) used to verify a quote. +//! +//! `Collateral` is re-exported from `tee-verifier-interface`, not redefined, +//! so it has a single canonical definition. +//! +//! The `test-utils` JSON parser below lives here, not in the wire crate: +//! `tee-verifier-interface` is Borsh-only on the cross-contract call, so +//! adding `serde_json` + `hex` there would bloat every consumer's WASM. The +//! only place collateral exists as JSON is off-chain test fixtures. +pub use tee_verifier_interface::Collateral; #[cfg(feature = "test-utils")] -use { - alloc::{string::String, vec::Vec}, - core::str::FromStr, - hex::FromHexError, - serde_json::Value, - thiserror::Error, -}; - -pub use dcap_qvl::QuoteCollateralV3; - -/// Supplemental data for the TEE quote, including Intel certificates to verify it came from genuine -/// Intel hardware, along with details about the Trusted Computing Base (TCB) versioning, status, -/// and other relevant info. -#[derive( - Clone, From, Deref, Into, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, -)] -#[cfg_attr(feature = "test-utils", serde(try_from = "Value"))] -pub struct Collateral(QuoteCollateralV3); +pub use parse::{CollateralError, collateral_from_json, collateral_from_str}; #[cfg(feature = "test-utils")] -impl Collateral { - /// Attempts to create a [`Collateral`] from a JSON value containing quote collateral data. - /// - /// # Errors - /// - /// Returns a [`CollateralError`] if: - /// - Any required field is missing or has an invalid type - /// - Hex fields cannot be decoded - pub fn try_from_json(v: Value) -> Result { +mod parse { + use super::Collateral; + use alloc::{string::String, vec::Vec}; + use hex::FromHexError; + use serde_json::Value; + use thiserror::Error; + + pub fn collateral_from_json(v: Value) -> Result { fn get_str(v: &Value, key: &str) -> Result { v.get(key) .and_then(Value::as_str) @@ -47,64 +36,37 @@ impl Collateral { }) } - let quote_collateral = QuoteCollateralV3 { + Ok(Collateral { + pck_crl_issuer_chain: get_str(&v, "pck_crl_issuer_chain")?, + root_ca_crl: get_hex(&v, "root_ca_crl")?, + pck_crl: get_hex(&v, "pck_crl")?, tcb_info_issuer_chain: get_str(&v, "tcb_info_issuer_chain")?, tcb_info: get_str(&v, "tcb_info")?, tcb_info_signature: get_hex(&v, "tcb_info_signature")?, qe_identity_issuer_chain: get_str(&v, "qe_identity_issuer_chain")?, qe_identity: get_str(&v, "qe_identity")?, qe_identity_signature: get_hex(&v, "qe_identity_signature")?, - pck_crl_issuer_chain: get_str(&v, "pck_crl_issuer_chain")?, - root_ca_crl: get_hex(&v, "root_ca_crl")?, - pck_crl: get_hex(&v, "pck_crl")?, pck_certificate_chain: get_str(&v, "pck_certificate_chain").ok(), - }; - Ok(Self(quote_collateral)) + }) } -} - -#[cfg(feature = "test-utils")] -impl FromStr for Collateral { - type Err = CollateralError; - /// Attempts to parse a JSON string into a [`Collateral`]. - /// - /// This is a convenience method that first parses the string as JSON, then attempts to convert - /// it to a [`Collateral`]. - /// - /// # Errors - /// - /// Returns a [`CollateralError`] if: - /// - The string is not valid JSON - /// - The JSON doesn't contain the required collateral fields - /// - Hex fields cannot be decoded - fn from_str(s: &str) -> Result { + pub fn collateral_from_str(s: &str) -> Result { let json_value: Value = serde_json::from_str(s).map_err(|_| CollateralError::InvalidJson)?; - Self::try_from_json(json_value) + collateral_from_json(json_value) } -} -#[cfg(feature = "test-utils")] -impl TryFrom for Collateral { - type Error = CollateralError; - - fn try_from(value: Value) -> Result { - Self::try_from_json(value) + #[derive(Debug, Error)] + pub enum CollateralError { + #[error("Missing or invalid field: {0}")] + MissingField(String), + #[error("Failed to decode hex field '{field}': {source}")] + HexDecode { + field: String, + #[source] + source: FromHexError, + }, + #[error("Invalid JSON format")] + InvalidJson, } } - -#[cfg(feature = "test-utils")] -#[derive(Debug, Error)] -pub enum CollateralError { - #[error("Missing or invalid field: {0}")] - MissingField(String), - #[error("Failed to decode hex field '{field}': {source}")] - HexDecode { - field: String, - #[source] - source: FromHexError, - }, - #[error("Invalid JSON format")] - InvalidJson, -} diff --git a/crates/attestation/src/dcap_conversions.rs b/crates/attestation/src/dcap_conversions.rs new file mode 100644 index 0000000000..03d52807e0 --- /dev/null +++ b/crates/attestation/src/dcap_conversions.rs @@ -0,0 +1,10 @@ +//! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in +//! `tee-verifier-interface`, for the off-chain `verify_locally` path. +//! +//! Re-exported from `tee-verifier-conversions` so the on-chain `tee-verifier` +//! contract and this off-chain crate share a single definition (and a single +//! Borsh-layout pin test suite) instead of duplicating the mappings. + +pub use tee_verifier_conversions::{ + IntoDcapType, IntoInterfaceType, collateral_from_dcap, collateral_into_dcap, +}; diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 1bc9d889ba..7e7f465497 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -5,6 +5,8 @@ extern crate alloc; pub mod app_compose; pub mod attestation; pub mod collateral; +#[cfg(feature = "local-verify")] +pub mod dcap_conversions; pub mod measurements; pub mod quote; pub mod report_data; diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index 7608b22b5b..1bb476fc4a 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -70,10 +70,12 @@ impl From<&crate::tcb_info::TcbInfo> for Measurements { } } -impl TryFrom for Measurements { +impl TryFrom for Measurements { type Error = MeasurementsError; - fn try_from(verified_report: dcap_qvl::verify::VerifiedReport) -> Result { + fn try_from( + verified_report: tee_verifier_interface::VerifiedReport, + ) -> Result { let td10 = verified_report .report .as_td10() diff --git a/crates/attestation/src/quote.rs b/crates/attestation/src/quote.rs index 826162eff9..585148dd91 100644 --- a/crates/attestation/src/quote.rs +++ b/crates/attestation/src/quote.rs @@ -1,22 +1,6 @@ -use alloc::vec::Vec; -use borsh::{BorshDeserialize, BorshSerialize}; -use derive_more::{Deref, From, Into}; -use serde::{Deserialize, Serialize}; - -#[derive( - Debug, - Clone, - From, - Into, - Deref, - Serialize, - Deserialize, - BorshDeserialize, - BorshSerialize, - PartialEq, - Eq, - PartialOrd, - Ord, -)] - -pub struct QuoteBytes(Vec); +//! Raw TDX/SGX quote bytes. +//! +//! Re-exported from `tee-verifier-interface` so the quote type has a single +//! definition shared by the verifier wire, this crate's post-DCAP logic, and +//! every consumer. +pub use tee_verifier_interface::QuoteBytes; diff --git a/crates/attestation/tests/collateral.rs b/crates/attestation/tests/collateral.rs index e56350f3c7..466ddd68f6 100644 --- a/crates/attestation/tests/collateral.rs +++ b/crates/attestation/tests/collateral.rs @@ -1,8 +1,5 @@ -use std::str::FromStr; - use assert_matches::assert_matches; -use attestation::collateral::{Collateral, CollateralError}; -use dcap_qvl::QuoteCollateralV3; +use attestation::collateral::{CollateralError, collateral_from_json, collateral_from_str}; use serde_json::json; use test_utils::attestation::collateral; @@ -12,7 +9,7 @@ fn test_collateral_missing_field() { // Remove a required field json_value.as_object_mut().unwrap().remove("tcb_info"); - let result = Collateral::try_from_json(json_value); + let result = collateral_from_json(json_value); assert_matches!(result, Err(CollateralError::MissingField(field)) => { assert_eq!(field, "tcb_info"); @@ -25,7 +22,7 @@ fn test_collateral_invalid_hex() { // Set invalid hex value json_value["tcb_info_signature"] = json!("not_valid_hex"); - let result = Collateral::try_from_json(json_value); + let result = collateral_from_json(json_value); assert_matches!(result, Err(CollateralError::HexDecode { field, ..}) => { assert_eq!(field, "tcb_info_signature"); @@ -38,7 +35,7 @@ fn test_collateral_null_field() { // Set field to null json_value["qe_identity"] = json!(null); - let result = Collateral::try_from_json(json_value); + let result = collateral_from_json(json_value); assert_matches!(result, Err(CollateralError::MissingField(field)) => { assert_eq!(field, "qe_identity"); @@ -51,7 +48,7 @@ fn test_collateral_wrong_type_field() { // Set field to wrong type (number instead of string) json_value["tcb_info_issuer_chain"] = json!(12345); - let result = Collateral::try_from_json(json_value); + let result = collateral_from_json(json_value); assert_matches!(result, Err(CollateralError::MissingField(field)) => { assert_eq!(field, "tcb_info_issuer_chain"); @@ -61,7 +58,7 @@ fn test_collateral_wrong_type_field() { #[test] fn test_hex_signature_lengths() { let json_value = collateral(); - let collateral = Collateral::try_from_json(json_value).unwrap(); + let collateral = collateral_from_json(json_value).unwrap(); // TCB info signature should be 64 hex chars (32 bytes) assert_eq!(collateral.tcb_info_signature.len(), 64); @@ -70,23 +67,17 @@ fn test_hex_signature_lengths() { } #[test] -fn test_derive_traits() { +fn test_collateral_parses_expected_fields() { let json_value = collateral(); - let collateral = Collateral::try_from_json(json_value.clone()).unwrap(); - - // Test From trait (should work through derive_more) - let quote_collateral_v3: QuoteCollateralV3 = collateral.into(); - assert!(quote_collateral_v3.tcb_info.contains("\"id\":\"TDX\"")); + let collateral = collateral_from_json(json_value).unwrap(); - // Test creating from QuoteCollateralV3 - let new_collateral = Collateral::from(quote_collateral_v3); - assert!(new_collateral.tcb_info.contains("\"id\":\"TDX\"")); + assert!(collateral.tcb_info.contains("\"id\":\"TDX\"")); } #[test] fn test_from_str_valid_json() { let json_str = serde_json::to_string(&collateral()).unwrap(); - let collateral = Collateral::from_str(&json_str).unwrap(); + let collateral = collateral_from_str(&json_str).unwrap(); assert!(collateral.tcb_info.contains("\"id\":\"TDX\"")); } @@ -94,7 +85,7 @@ fn test_from_str_valid_json() { #[test] fn test_from_str_invalid_json() { let invalid_json = "{ invalid json }"; - let result = Collateral::from_str(invalid_json); + let result = collateral_from_str(invalid_json); assert_matches!(result, Err(CollateralError::InvalidJson)); } diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 72553d8ab4..8a2f7c4eb2 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -87,7 +87,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true } +mpc-attestation = { workspace = true, features = ["local-verify"] } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 7f171172e2..b2d71e2232 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -10,7 +10,7 @@ use mpc_attestation::{ Attestation, DstackAttestation, ExpectedMeasurements, Measurements, MockAttestation, VerifiedAttestation, }, - collateral::{Collateral, QuoteCollateralV3}, + collateral::Collateral, tcb_info::{EventLog, HexBytes, TcbInfo}, }; use near_mpc_contract_interface::types as dtos; @@ -124,7 +124,9 @@ impl IntoContractType for dtos::Collateral { pck_certificate_chain, } = self; - Collateral::from(QuoteCollateralV3 { + // TODO(#3494): drop this conversion once `dtos::DstackAttestation` + // carries `tee_verifier_interface::Collateral` directly. + Collateral { pck_crl_issuer_chain, root_ca_crl: root_ca_crl.into(), pck_crl: pck_crl.into(), @@ -135,7 +137,7 @@ impl IntoContractType for dtos::Collateral { qe_identity, qe_identity_signature: qe_identity_signature.into(), pck_certificate_chain, - }) + } } } @@ -326,8 +328,9 @@ impl IntoInterfaceType for DstackAttestation { impl IntoInterfaceType for Collateral { fn into_dto_type(self) -> dtos::Collateral { - // Collateral is a newtype wrapper around QuoteCollateralV3 - let QuoteCollateralV3 { + // TODO(#3494): drop this conversion once `dtos` carries the interface + // `Collateral` directly. + let Collateral { pck_crl_issuer_chain, root_ca_crl, pck_crl, @@ -338,7 +341,7 @@ impl IntoInterfaceType for Collateral { qe_identity, qe_identity_signature, pck_certificate_chain, - } = self.into(); + } = self; dtos::Collateral { pck_crl_issuer_chain, diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 695de4f046..6d74a8f0e7 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -161,10 +161,12 @@ impl TeeState { .into(); let accepted_measurements = self.get_accepted_measurements(); + // TODO(#3264): run DCAP in the verifier contract (Promise + callback) and + // do the post-DCAP checks here, instead of verifying locally in-WASM. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify( + } = attestation.verify_locally( expected_report_data.into(), Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), diff --git a/crates/mpc-attestation/Cargo.toml b/crates/mpc-attestation/Cargo.toml index 3c014d45a5..b0c32c17d2 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -8,6 +8,12 @@ edition = { workspace = true } abi = ["borsh/unstable__schema", "mpc-primitives/abi", "attestation/borsh-schema"] dstack-conversions = ["attestation/dstack-conversions"] test-utils = ["attestation/test-utils"] +# Enables full local DCAP + post-DCAP verification, forwarding to +# `attestation/local-verify` which pulls in `dcap-qvl`. Used off-chain (node, +# tee-authority, attestation-cli) and, for now, by the contract's synchronous +# attestation path. +# TODO(#3264): contract drops this once DCAP moves to the verifier contract. +local-verify = ["attestation/local-verify"] [dependencies] attestation = { workspace = true } @@ -21,10 +27,12 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } sha3 = { workspace = true } +tee-verifier-interface = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } dcap-qvl = { workspace = true } +mpc-attestation = { path = ".", features = ["local-verify", "test-utils"] } test-utils = { workspace = true } [lints] diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index b4b8b88f4b..1f6e6a517d 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -11,6 +11,7 @@ use attestation::{ 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; @@ -40,9 +41,11 @@ pub enum VerifiedAttestation { Mock(MockAttestation), } -/// Result of a successful [`Attestation::verify`] call. +/// 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 @@ -51,6 +54,39 @@ pub struct AcceptedAttestation { 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( @@ -73,6 +109,89 @@ pub enum MockAttestation { }, } +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")), @@ -123,12 +242,11 @@ impl VerifiedAttestation { Ok(()) } - Self::Mock(mock_attestation) => verify_mock_attestation( - mock_attestation, + Self::Mock(mock_attestation) => mock_attestation.verify_constraints( + timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, allowed_measurements, - timestamp_seconds, ), } } @@ -144,12 +262,63 @@ pub fn default_measurements() -> &'static [ExpectedMeasurements] { &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. - /// - /// On success, returns an [`AcceptedAttestation`]. - pub fn verify( + /// 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], @@ -157,90 +326,104 @@ impl Attestation { accepted_measurements: &[ExpectedMeasurements], ) -> Result { match self { - Self::Dstack(dstack_attestation) => { - // Makes MPC related attestation verification first - 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, - )?; + 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, + ), + } + } - let AcceptedDstackAttestation { - measurements, - advisory_ids, - } = dstack_attestation.verify( + /// 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, - accepted_measurements, - )?; - - // TODO(#1639): extract timestamp from certificate itself - let expiration_timestamp_seconds = - current_timestamp_seconds + DEFAULT_EXPIRATION_DURATION_SECONDS; - Ok(AcceptedAttestation { - attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { - mpc_image_hash, - launcher_compose_hash, - expiry_timestamp_seconds: expiration_timestamp_seconds, - measurements, - }), - advisory_ids, - }) - } - Self::Mock(mock_attestation) => { - // Override attestation verification for this case - let () = verify_mock_attestation( - mock_attestation, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, - current_timestamp_seconds, - )?; - - Ok(AcceptedAttestation { - attestation: VerifiedAttestation::Mock(mock_attestation.clone()), - advisory_ids: Vec::new(), - }) + ) } + 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, @@ -300,69 +483,6 @@ fn verify_measurements( Ok(()) } -pub(crate) fn verify_mock_attestation( - mock_attestation: &MockAttestation, - allowed_mpc_docker_image_hashes: &[NodeImageHash], - allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], - allowed_measurements: &[ExpectedMeasurements], - timestamp_seconds: u64, -) -> Result<(), VerificationError> { - match mock_attestation { - 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 { - (timestamp_seconds < *expiry_timestamp).or_err(|| { - VerificationError::ExpiredCertificate { - attestation_time: timestamp_seconds, - expiry_time: *expiry_timestamp, - } - })?; - }; - - if let Some(measurements) = expected_measurements { - verify_measurements(measurements, allowed_measurements)?; - } - - Ok(()) - } - } -} - #[cfg(test)] mod tests { use alloc::vec; diff --git a/crates/mpc-attestation/src/lib.rs b/crates/mpc-attestation/src/lib.rs index 462ea2b6fc..54584a810b 100644 --- a/crates/mpc-attestation/src/lib.rs +++ b/crates/mpc-attestation/src/lib.rs @@ -5,4 +5,6 @@ extern crate alloc; pub mod attestation; pub mod report_data; +#[cfg(feature = "local-verify")] +pub use ::attestation::dcap_conversions; pub use ::attestation::{collateral, quote, tcb_info}; diff --git a/crates/mpc-attestation/src/report_data.rs b/crates/mpc-attestation/src/report_data.rs index 41e2d8873c..db1dd7a853 100644 --- a/crates/mpc-attestation/src/report_data.rs +++ b/crates/mpc-attestation/src/report_data.rs @@ -168,14 +168,17 @@ impl From for ::attestation::report_data::ReportData { mod tests { use super::*; use crate::report_data::ReportData; - use alloc::vec::Vec; - use dcap_qvl::quote::Quote; - use test_utils::attestation::{account_key, p2p_tls_key, quote}; + use test_utils::attestation::{account_key, p2p_tls_key}; + #[cfg(feature = "local-verify")] + use { + alloc::vec::Vec, dcap_qvl::quote::Quote, test_utils::attestation::quote as quote_fixture, + }; + // Parses a real quote with `dcap-qvl`, so it is off-chain only. + #[cfg(feature = "local-verify")] #[test] fn test_from_str_valid() { - let valid_quote: Vec = - serde_json::from_str(&serde_json::to_string("e()).unwrap()).unwrap(); + let valid_quote: Vec = quote_fixture().into(); let quote = Quote::parse(&valid_quote).unwrap(); let td_report = quote.report.as_td10().expect("Should be a TD 1.0 report"); diff --git a/crates/mpc-attestation/tests/test_attestation_verification.rs b/crates/mpc-attestation/tests/test_attestation_verification.rs index ad205ace82..36d20c4ff3 100644 --- a/crates/mpc-attestation/tests/test_attestation_verification.rs +++ b/crates/mpc-attestation/tests/test_attestation_verification.rs @@ -1,3 +1,7 @@ +//! Exercises the full local DCAP + post-DCAP path (`verify_locally`), so it +//! requires the off-chain `local-verify` feature. +#![cfg(feature = "local-verify")] + use assert_matches::assert_matches; use attestation::attestation::VerificationError; use attestation::measurements::{ExpectedMeasurements, Measurements}; @@ -21,7 +25,7 @@ fn valid_mock_attestation_succeeds_verification() { let report_data = ReportData::V1(ReportDataV1::new(tls_key, account_key)); assert_matches!( - valid_attestation.verify(report_data.into(), timestamp_s, &[], &[], &[]), + valid_attestation.verify_locally(report_data.into(), timestamp_s, &[], &[], &[]), Ok(AcceptedAttestation { attestation: VerifiedAttestation::Mock(MockAttestation::Valid), advisory_ids, @@ -39,7 +43,7 @@ fn invalid_mock_attestation_fails_verification() { let report_data = ReportData::V1(ReportDataV1::new(tls_key, account_key)); assert_matches!( - valid_attestation.verify(report_data.into(), timestamp_s, &[], &[], &[]), + valid_attestation.verify_locally(report_data.into(), timestamp_s, &[], &[], &[]), Err(VerificationError::InvalidMockAttestation) ); } @@ -56,7 +60,7 @@ fn validated_dstack_attestation_can_be_reverified() { let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), timestamp_s, &allowed_mpc_hashes, @@ -90,7 +94,7 @@ fn validated_dstack_attestation_fails_reverification_when_expired() { let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), timestamp_s, &allowed_mpc_hashes, @@ -123,7 +127,7 @@ fn validated_mock_attestation_passes_reverification() { let report_data: ReportData = ReportDataV1::new(tls_key, account_key).into(); let validated = valid_attestation - .verify(report_data.into(), 0, &[], &[], &[]) + .verify_locally(report_data.into(), 0, &[], &[], &[]) .expect("Initial verification failed") .attestation; @@ -144,7 +148,7 @@ fn validated_dstack_attestation_fails_reverification_with_rotated_hashes() { // 1. Initial verify succeeds with the "old" allowed list let validated = attestation - .verify( + .verify_locally( report_data.into(), creation_time, &allowed_mpc_hashes, @@ -183,7 +187,7 @@ fn validated_dstack_attestation_fails_reverification_with_removed_measurements() let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), creation_time, &allowed_mpc_hashes, @@ -227,7 +231,7 @@ fn validated_dstack_attestation_fails_reverification_with_empty_measurements() { let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), creation_time, &allowed_mpc_hashes, @@ -261,7 +265,7 @@ fn validated_dstack_attestation_passes_reverification_with_superset_measurements let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), creation_time, &allowed_mpc_hashes, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 0392022074..6e3172af73 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -37,7 +37,7 @@ itertools = { workspace = true } k256 = { workspace = true } launcher-interface = { workspace = true } lru = { workspace = true } -mpc-attestation = { workspace = true } +mpc-attestation = { workspace = true, features = ["local-verify"] } mpc-node-config = { workspace = true } mpc-primitives = { workspace = true } mpc-tls = { workspace = true } diff --git a/crates/node/src/tee/remote_attestation.rs b/crates/node/src/tee/remote_attestation.rs index caaf85824d..ef099a8470 100644 --- a/crates/node/src/tee/remote_attestation.rs +++ b/crates/node/src/tee/remote_attestation.rs @@ -103,7 +103,7 @@ fn validate_remote_attestation( .unwrap() .as_secs(); attestation - .verify( + .verify_locally( expected_report_data.into(), now, allowed_docker_image_hashes, diff --git a/crates/node/src/trait_extensions/convert_to_contract_dto.rs b/crates/node/src/trait_extensions/convert_to_contract_dto.rs index 005ac1d833..65d99b9717 100644 --- a/crates/node/src/trait_extensions/convert_to_contract_dto.rs +++ b/crates/node/src/trait_extensions/convert_to_contract_dto.rs @@ -7,7 +7,7 @@ use mpc_attestation::{ attestation::{Attestation, DstackAttestation, MockAttestation}, - collateral::{Collateral, QuoteCollateralV3}, + collateral::Collateral, tcb_info::{EventLog, TcbInfo}, }; @@ -86,8 +86,9 @@ impl IntoContractInterfaceType for Collateral { fn into_contract_interface_type(self) -> near_mpc_contract_interface::types::Collateral { - // Collateral is a newtype wrapper around QuoteCollateralV3 - let QuoteCollateralV3 { + // TODO(#3494): drop this conversion once the DTO carries the interface + // `Collateral` directly. + let Collateral { pck_crl_issuer_chain, root_ca_crl, pck_crl, @@ -98,7 +99,7 @@ impl IntoContractInterfaceType f qe_identity, qe_identity_signature, pck_certificate_chain, - } = self.into(); + } = self; near_mpc_contract_interface::types::Collateral { pck_crl_issuer_chain, diff --git a/crates/tee-authority/Cargo.toml b/crates/tee-authority/Cargo.toml index 73e9a0d6bb..e745722ff1 100644 --- a/crates/tee-authority/Cargo.toml +++ b/crates/tee-authority/Cargo.toml @@ -15,7 +15,7 @@ derive_more = { workspace = true } dstack-sdk = { workspace = true } hex = { workspace = true } launcher-interface = { workspace = true } -mpc-attestation = { workspace = true, features = ["dstack-conversions"] } +mpc-attestation = { workspace = true, features = ["dstack-conversions", "local-verify"] } near-mpc-bounded-collections = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/crates/tee-authority/src/tee_authority.rs b/crates/tee-authority/src/tee_authority.rs index 2dd157b548..3449be7dd7 100644 --- a/crates/tee-authority/src/tee_authority.rs +++ b/crates/tee-authority/src/tee_authority.rs @@ -231,10 +231,10 @@ const PCCS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); /// 7 days is stricter than Intel's 30-day `nextUpdate` window but /// more permissive than any default PCCS refresh schedule (Intel /// reference and Phala both refresh ~daily), so legitimate operators -/// have ample headroom. The choice aligns with the contract's -/// `DEFAULT_EXPIRATION_DURATION_SECONDS`: any attestation accepted by -/// the contract is ≤7 days old *and* backed by collateral whose Intel -/// signature is ≤7 days old. +/// have ample headroom. This is the freshness bound on the collateral's +/// Intel signature and is independent of the contract's attestation +/// expiry (`DEFAULT_EXPIRATION_DURATION_SECONDS`); the two windows serve +/// different purposes and are not required to match. /// /// Applies uniformly to the three periodically re-signed pieces of /// collateral that share Intel's 30-day window: `tcb_info.issueDate`, @@ -495,7 +495,7 @@ impl TeeAuthority { url: endpoint.url.clone(), timeout: PCCS_REQUEST_TIMEOUT, })? - .map(Collateral::from) + .map(mpc_attestation::dcap_conversions::collateral_from_dcap) .map_err(|e| PccsEndpointError::Fetch { url: endpoint.url.clone(), source: anyhow::anyhow!(e), @@ -770,7 +770,7 @@ mod tests { let timestamp_s = 0u64; assert_eq!( attestation - .verify(report_data.into(), timestamp_s, &[], &[], &[]) + .verify_locally(report_data.into(), timestamp_s, &[], &[], &[]) .is_ok(), quote_verification_result ); @@ -922,7 +922,7 @@ mod tests { /// inspected — it just needs to be a valid value that round-trips through /// the fetch path. fn dummy_collateral(tag: &str) -> Collateral { - dcap_qvl::QuoteCollateralV3 { + mpc_attestation::dcap_conversions::collateral_from_dcap(dcap_qvl::QuoteCollateralV3 { pck_crl_issuer_chain: tag.into(), root_ca_crl: Vec::new(), pck_crl: Vec::new(), @@ -933,8 +933,7 @@ mod tests { qe_identity: String::new(), qe_identity_signature: Vec::new(), pck_certificate_chain: None, - } - .into() + }) } fn endpoints(list: &[&str]) -> NonEmptyVec { @@ -1131,7 +1130,7 @@ mod tests { /// that exercise the JSON path get a fresh-enough CRL by virtue of /// the [`test_now`] choice. fn collateral_with_issue_dates(tcb_info_iso: &str, qe_identity_iso: &str) -> Collateral { - dcap_qvl::QuoteCollateralV3 { + mpc_attestation::dcap_conversions::collateral_from_dcap(dcap_qvl::QuoteCollateralV3 { pck_crl_issuer_chain: String::new(), root_ca_crl: Vec::new(), pck_crl: fixture_pck_crl(), @@ -1142,8 +1141,7 @@ mod tests { qe_identity: format!(r#"{{"issueDate":"{qe_identity_iso}"}}"#), qe_identity_signature: Vec::new(), pck_certificate_chain: None, - } - .into() + }) } /// Format an `OffsetDateTime` as RFC3339 (UTC) the way Intel PCS would @@ -1369,7 +1367,8 @@ mod tests { root_ca_crl, pck_crl, pck_certificate_chain, - }: dcap_qvl::QuoteCollateralV3 = collateral.into(); + }: dcap_qvl::QuoteCollateralV3 = + mpc_attestation::dcap_conversions::collateral_into_dcap(collateral); assert!(!tcb_info_issuer_chain.is_empty()); assert!(!tcb_info.is_empty()); diff --git a/crates/tee-verifier-conversions/Cargo.toml b/crates/tee-verifier-conversions/Cargo.toml new file mode 100644 index 0000000000..b112582332 --- /dev/null +++ b/crates/tee-verifier-conversions/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tee-verifier-conversions" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dependencies] +dcap-qvl = { workspace = true } +tee-verifier-interface = { workspace = true } + +[dev-dependencies] +borsh = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tee-verifier/src/conversions.rs b/crates/tee-verifier-conversions/src/lib.rs similarity index 84% rename from crates/tee-verifier/src/conversions.rs rename to crates/tee-verifier-conversions/src/lib.rs index ef1377567f..e0907b972e 100644 --- a/crates/tee-verifier/src/conversions.rs +++ b/crates/tee-verifier-conversions/src/lib.rs @@ -1,10 +1,22 @@ //! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in -//! `tee-verifier-interface`. They live here, not in the interface crate, so -//! that crate stays `no_std` and free of `dcap-qvl`. +//! `tee-verifier-interface`. +//! +//! Shared by the on-chain `tee-verifier` contract and the off-chain +//! `attestation` crate's `verify_locally` path. +//! +//! These mappings need only `dcap-qvl` + `tee-verifier-interface` + `borsh`, so +//! they live in this minimal crate. Putting them in `attestation` instead would +//! force the contract to pull that crate's unrelated dependencies (`serde`, +//! `sha2`, etc.) into its WASM. //! //! Mapped with the local [`IntoDcapType`] / [`IntoInterfaceType`] traits. We //! can not use [`From`] and [`Into`] due to the [*orphan rule*](https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules). +#![no_std] + +extern crate alloc; + +use alloc::vec::Vec; use dcap_qvl::{quote as dq_quote, tcb_info as dq_tcb, verify as dq_verify}; use tee_verifier_interface::{ Collateral, EnclaveReport, QuoteBytes, Report, TDReport10, TDReport15, TcbStatus, @@ -12,12 +24,12 @@ use tee_verifier_interface::{ }; /// Converts an interface type into its `dcap_qvl` counterpart `T`. -pub(crate) trait IntoDcapType { +pub trait IntoDcapType { fn into_dcap_type(self) -> T; } /// Converts a `dcap_qvl` type into its `tee-verifier-interface` counterpart `T`. -pub(crate) trait IntoInterfaceType { +pub trait IntoInterfaceType { fn into_interface_type(self) -> T; } @@ -38,6 +50,31 @@ impl IntoDcapType for Collateral { } } +impl IntoInterfaceType for dcap_qvl::QuoteCollateralV3 { + fn into_interface_type(self) -> Collateral { + Collateral { + pck_crl_issuer_chain: self.pck_crl_issuer_chain, + root_ca_crl: self.root_ca_crl, + pck_crl: self.pck_crl, + tcb_info_issuer_chain: self.tcb_info_issuer_chain, + tcb_info: self.tcb_info, + tcb_info_signature: self.tcb_info_signature, + qe_identity_issuer_chain: self.qe_identity_issuer_chain, + qe_identity: self.qe_identity, + qe_identity_signature: self.qe_identity_signature, + pck_certificate_chain: self.pck_certificate_chain, + } + } +} + +pub fn collateral_from_dcap(collateral: dcap_qvl::QuoteCollateralV3) -> Collateral { + collateral.into_interface_type() +} + +pub fn collateral_into_dcap(collateral: Collateral) -> dcap_qvl::QuoteCollateralV3 { + collateral.into_dcap_type() +} + impl IntoDcapType> for QuoteBytes { fn into_dcap_type(self) -> Vec { self.0 @@ -159,6 +196,7 @@ impl IntoInterfaceType for dq_tcb::TcbStatusWithAdvisory #[expect(non_snake_case)] mod tests { use super::*; + use alloc::{string::ToString, vec}; use rstest::rstest; /// Asserts the two values encode to identical Borsh bytes. @@ -173,16 +211,16 @@ mod tests { fn sample_collateral() -> Collateral { Collateral { - pck_crl_issuer_chain: "issuer-chain".into(), + pck_crl_issuer_chain: "issuer-chain".to_string(), root_ca_crl: vec![1, 2, 3], pck_crl: vec![4, 5, 6], - tcb_info_issuer_chain: "tcb-issuer".into(), - tcb_info: "tcb-info-json".into(), + tcb_info_issuer_chain: "tcb-issuer".to_string(), + tcb_info: "tcb-info-json".to_string(), tcb_info_signature: vec![7, 8], - qe_identity_issuer_chain: "qe-issuer".into(), - qe_identity: "qe-identity-json".into(), + qe_identity_issuer_chain: "qe-issuer".to_string(), + qe_identity: "qe-identity-json".to_string(), qe_identity_signature: vec![9, 10], - pck_certificate_chain: Some("pck-chain".into()), + pck_certificate_chain: Some("pck-chain".to_string()), } } @@ -233,8 +271,8 @@ mod tests { fn dcap_verified_report(report: dq_quote::Report) -> dq_verify::VerifiedReport { dq_verify::VerifiedReport { - status: "UpToDate".into(), - advisory_ids: vec!["INTEL-SA-00001".into()], + status: "UpToDate".to_string(), + advisory_ids: vec!["INTEL-SA-00001".to_string()], report, ppid: vec![0xAB; 16], qe_status: dq_tcb::TcbStatusWithAdvisory { @@ -243,7 +281,7 @@ mod tests { }, platform_status: dq_tcb::TcbStatusWithAdvisory { status: dq_tcb::TcbStatus::ConfigurationNeeded, - advisory_ids: vec!["INTEL-SA-00002".into()], + advisory_ids: vec!["INTEL-SA-00002".to_string()], }, } } @@ -321,7 +359,7 @@ mod tests { fn tcb_status_with_advisory__should_match_dcap_borsh_layout() { let dcap = dq_tcb::TcbStatusWithAdvisory { status: dq_tcb::TcbStatus::ConfigurationNeeded, - advisory_ids: vec!["INTEL-SA-00003".into()], + advisory_ids: vec!["INTEL-SA-00003".to_string()], }; let interface: TcbStatusWithAdvisory = dcap.clone().into_interface_type(); assert_same_borsh_bytes(&interface, &dcap); diff --git a/crates/tee-verifier-interface/Cargo.toml b/crates/tee-verifier-interface/Cargo.toml index 6316ec070f..8b486d4f7b 100644 --- a/crates/tee-verifier-interface/Cargo.toml +++ b/crates/tee-verifier-interface/Cargo.toml @@ -6,10 +6,21 @@ edition = { workspace = true } [features] borsh-schema = ["borsh/unstable__schema"] +# Off by default. Derives serde only on the verifier *input* types +# (`QuoteBytes`, `Collateral`) for off-chain callers that embed them in serde +# structs. The Borsh cross-contract ABI never enables it. +serde = ["dep:serde"] [dependencies] borsh = { workspace = true } derive_more = { workspace = true } +# Declared directly (not via the workspace) so this `no_std` crate can pin +# `default-features = false`; the workspace `serde` enables `std`. Off by +# default via the `serde` feature. +serde = { version = "1.0", optional = true, default-features = false, features = [ + "derive", + "alloc", +] } [dev-dependencies] rstest = { workspace = true } diff --git a/crates/tee-verifier-interface/src/lib.rs b/crates/tee-verifier-interface/src/lib.rs index 2dc6977edd..5dd2c3467a 100644 --- a/crates/tee-verifier-interface/src/lib.rs +++ b/crates/tee-verifier-interface/src/lib.rs @@ -3,21 +3,10 @@ //! Field-for-field mirrors of the `dcap_qvl` input and output types, //! owned here so the Borsh wire layout is independent of upstream. //! -//! This crate is the *only* DTO crate a consumer (`mpc-contract`, future -//! Proximity / Defuse contracts) needs in order to talk to the verifier — -//! without re-linking the `dcap-qvl` / `ring` / `webpki` / `x509-cert` -//! closure into its own WASM. The crate is `no_std` and has no -//! `dcap-qvl` dependency; the `From` conversions live in -//! the `tee-verifier` contract crate, the only crate that depends on -//! both. -//! -//! Borsh-only on purpose. The verifier is reached only over a cross-contract -//! call (Borsh ABI), so there is no JSON wire and serde would just add -//! dependencies. The payload is mostly binary anyway (a multi-KB quote -//! plus collateral), which Borsh sends as raw bytes where JSON would inflate -//! it into integer arrays. Byte fields stay plain `Vec` / arrays rather -//! than serde/hex wrappers, which also keeps the layout a field-for-field -//! Borsh mirror of `dcap_qvl`. +//! It is the only DTO crate a consumer (`mpc-contract` and future external +//! contracts) needs to talk to the verifier, without linking `dcap-qvl` into +//! its own WASM. `no_std`, no `dcap-qvl` dependency; the `dcap_qvl` conversions +//! live in `tee-verifier-conversions`. #![no_std] @@ -43,6 +32,11 @@ use borsh::{BorshDeserialize, BorshSerialize}; derive_more::Into, )] #[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] +// The off-by-default `serde` feature is for off-chain callers that embed the +// verifier *input* types (`QuoteBytes`, `Collateral`) in serde structs. The +// Borsh cross-contract ABI never enables it; the report/output types stay +// Borsh-only. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct QuoteBytes(pub Vec); /// Quote collateral, mirroring `dcap_qvl::QuoteCollateralV3`. @@ -51,6 +45,9 @@ pub struct QuoteBytes(pub Vec); /// encoding of `QuoteCollateralV3`. #[derive(Debug, Clone, BorshSerialize, BorshDeserialize, PartialEq, Eq)] #[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] +// See the note on [`QuoteBytes`]: the off-by-default `serde` feature is for +// off-chain callers only and covers just the verifier input types. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Collateral { pub pck_crl_issuer_chain: String, pub root_ca_crl: Vec, diff --git a/crates/tee-verifier/Cargo.toml b/crates/tee-verifier/Cargo.toml index ef6cef3da1..22189baee9 100644 --- a/crates/tee-verifier/Cargo.toml +++ b/crates/tee-verifier/Cargo.toml @@ -35,6 +35,7 @@ test-utils = ["near-sdk/unit-testing"] borsh = { workspace = true } dcap-qvl = { workspace = true } near-sdk = { workspace = true } +tee-verifier-conversions = { workspace = true } tee-verifier-interface = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -42,7 +43,6 @@ getrandom = { workspace = true, features = ["custom"] } [dev-dependencies] hex = { workspace = true } -rstest = { workspace = true } tee-verifier = { path = ".", features = ["test-utils"] } test-utils = { workspace = true } diff --git a/crates/tee-verifier/src/lib.rs b/crates/tee-verifier/src/lib.rs index b44d711495..7689c527cc 100644 --- a/crates/tee-verifier/src/lib.rs +++ b/crates/tee-verifier/src/lib.rs @@ -11,8 +11,7 @@ use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; -mod conversions; -use conversions::{IntoDcapType as _, IntoInterfaceType as _}; +use tee_verifier_conversions::{IntoDcapType as _, IntoInterfaceType as _}; // `dcap-qvl`'s `contract` feature pulls in `getrandom` but doesn't enable // any backend. On `wasm32-unknown-unknown` we register a custom impl that diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 5cac9b4f28..3c4af6c72e 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -65,9 +65,10 @@ pub fn collateral() -> Value { } pub fn quote() -> QuoteBytes { - let quote_collateral_json_string = include_str!("../assets/quote.json"); - serde_json::from_str(quote_collateral_json_string) - .expect("Quote collateral file is a valid json.") + let quote_json_string = include_str!("../assets/quote.json"); + let bytes: Vec = + serde_json::from_str(quote_json_string).expect("Quote file is a valid json byte array."); + QuoteBytes::from(bytes) } pub fn p2p_tls_key() -> [u8; 32] { @@ -98,7 +99,8 @@ pub fn near_account_key() -> near_sdk::PublicKey { pub fn mock_dstack_attestation() -> Attestation { let quote = quote(); let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = serde_json::from_str(collateral_json_string).unwrap(); + let collateral = mpc_attestation::collateral::collateral_from_str(collateral_json_string) + .expect("collateral.json is valid collateral"); let tcb_info: TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap();