From e3dc354581e90deee8651c5046f3da3894bdcde5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 18:45:21 +0200 Subject: [PATCH 01/21] refactor(attestation): split DCAP verification from post-DCAP checks Move the heavy `dcap_qvl::verify::verify` call out of the always-compiled attestation path and behind an off-chain `local-verify` feature, so the post-DCAP checks can run against a `VerifiedReport` supplied by the verifier contract (on-chain) or by local DCAP (off-chain). - `DstackAttestation` / `Attestation` gain `verify_with_report` (pure, no dcap-qvl) and `verify_locally` (off-chain, `local-verify`). The contract temporarily keeps calling `verify_locally`; the cross-contract verifier call that removes dcap-qvl from the contract WASM lands in a later step. - Collapse the duplicate `Collateral` / `QuoteBytes` definitions: `attestation` now re-exports the `tee-verifier-interface` types instead of wrapping `dcap_qvl::QuoteCollateralV3`, removing dcap-qvl from `attestation`'s default dependency graph. The interface crate gains an off-by-default `serde` feature (input types only) for the node's `/public_data` payload. - dcap<->interface conversions get an `attestation`-local copy (kept out of the minimal verifier contract on purpose), pinned by a borsh-layout test. Behavior-neutral: off-chain callers (node, tee-authority, attestation-cli) and the contract verify exactly as before. --- Cargo.lock | 5 + crates/attestation-cli/Cargo.toml | 2 +- crates/attestation-cli/src/verify.rs | 5 +- crates/attestation/Cargo.toml | 17 +- crates/attestation/src/attestation.rs | 111 ++++-- crates/attestation/src/collateral.rs | 116 +++---- crates/attestation/src/dcap_conversions.rs | 328 ++++++++++++++++++ crates/attestation/src/lib.rs | 2 + crates/attestation/src/measurements.rs | 6 +- crates/attestation/src/quote.rs | 28 +- crates/attestation/tests/collateral.rs | 35 +- crates/contract/Cargo.toml | 4 +- crates/contract/src/dto_mapping.rs | 15 +- crates/contract/src/tee/tee_state.rs | 6 +- crates/mpc-attestation/Cargo.toml | 10 + crates/mpc-attestation/src/attestation.rs | 230 ++++++++---- crates/mpc-attestation/src/lib.rs | 2 + crates/mpc-attestation/src/report_data.rs | 13 +- .../tests/test_attestation_verification.rs | 78 ++++- crates/node/Cargo.toml | 2 +- crates/node/src/tee/remote_attestation.rs | 2 +- .../convert_to_contract_dto.rs | 9 +- crates/tee-authority/Cargo.toml | 2 +- crates/tee-authority/src/tee_authority.rs | 17 +- crates/tee-verifier-interface/Cargo.toml | 12 + crates/tee-verifier-interface/src/lib.rs | 11 + crates/test-utils/src/attestation.rs | 12 +- 27 files changed, 820 insertions(+), 260 deletions(-) create mode 100644 crates/attestation/src/dcap_conversions.rs diff --git a/Cargo.lock b/Cargo.lock index 6df4df17b6..40354e16c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,7 @@ name = "attestation" version = "3.11.0" dependencies = [ "assert_matches", + "attestation", "borsh", "dcap-qvl", "derive_more 2.1.1", @@ -986,6 +987,7 @@ dependencies = [ "serde_json", "serde_with", "sha2 0.10.9", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", ] @@ -5751,11 +5753,13 @@ dependencies = [ "hex", "include-measurements", "launcher-interface", + "mpc-attestation", "mpc-primitives", "serde", "serde_json", "sha2 0.10.9", "sha3", + "tee-verifier-interface", "test-utils", ] @@ -11385,6 +11389,7 @@ dependencies = [ "borsh", "derive_more 2.1.1", "rstest", + "serde", ] [[package]] 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..574d244f2c 100644 --- a/crates/attestation-cli/src/verify.rs +++ b/crates/attestation-cli/src/verify.rs @@ -70,11 +70,12 @@ pub fn verify_at_timestamp( VerificationError::Custom(format!("failed to load expected measurements: {e}")) })?; - // Single verify call — same verification logic as the contract and node + // Full local verification (DCAP + post-DCAP) — the CLI verifies end-to-end + // locally, the same post-DCAP logic the contract runs on the verifier's report. 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..b1c7899831 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -5,13 +5,18 @@ 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 = [] +# Off-chain only: pulls in `dcap-qvl` for `DstackAttestation::verify_locally` +# (full local DCAP + post-DCAP verification). On-chain callers (the contract) +# do not enable this; they get the `VerifiedReport` from the verifier contract +# and call `verify_with_report` directly. +local-verify = ["dep:dcap-qvl"] [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 +24,18 @@ serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } sha2 = { workspace = true } +# `serde` feature: `DstackAttestation` re-exports and embeds the interface +# `Collateral`/`QuoteBytes` and derives serde on them (needed for the node's +# `/public_data` payload). This is the only place that turns the interface +# crate's off-by-default `serde` feature on. +tee-verifier-interface = { workspace = true, features = ["serde"] } thiserror = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } +# Self-dependency enabling the off-chain features so unit tests can exercise +# the `dcap_conversions` Borsh-layout pin and `verify_locally`. +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..38a1648b39 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -17,6 +17,7 @@ use core::fmt; use derive_more::Constructor; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256, Sha384}; +use tee_verifier_interface::{TDReport10, VerifiedReport}; /// Expected TCB status for a successfully verified TEE quote. const EXPECTED_QUOTE_STATUS: &str = "UpToDate"; @@ -30,6 +31,11 @@ pub(crate) const KEY_PROVIDER_EVENT: &str = "key-provider"; const RTMR3_INDEX: u32 = 3; +// `quote` and `collateral` are the `tee-verifier-interface` mirrors; their +// serde impls come from that crate's off-by-default `serde` feature, which +// `attestation` enables. Serde is needed because `DstackAttestation` is +// embedded (via `mpc_attestation::Attestation`) in the node's serde-serialized +// `/public_data` HTTP payload. #[derive(Clone, Constructor, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] pub struct DstackAttestation { pub quote: QuoteBytes, @@ -123,33 +129,30 @@ 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. + /// Runs the post-DCAP checks against an already-verified report. /// - /// On success, returns the matched measurements along with any informational - /// advisory IDs surfaced alongside an `UpToDate` TCB status. - pub fn verify( + /// Pure: no `dcap-qvl`, no host calls. The DCAP cryptographic verification + /// (`dcap_qvl::verify::verify`) is done elsewhere — by the `tee-verifier` + /// contract on-chain, or by [`verify_locally`](Self::verify_locally) + /// off-chain — and its `VerifiedReport` is passed in here. This is the + /// function both the contract and the off-chain helper share. + /// + /// `report` must be the verifier's output for *this* attestation's quote + /// and collateral; the checks below bind it to the expected report data, + /// the embedded TCB info, and the accepted measurement sets. + 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 +166,45 @@ impl DstackAttestation { }) } + /// Full local verification: runs `dcap_qvl::verify::verify` and then the + /// post-DCAP checks via [`verify_with_report`](Self::verify_with_report). + /// + /// Off-chain only (the `local-verify` feature pulls in `dcap-qvl`). Used by + /// the node, `tee-authority`, and `attestation-cli` to verify an + /// attestation end-to-end without the verifier contract. On-chain, + /// `mpc-contract` instead calls the verifier contract for the DCAP step and + /// then `verify_with_report` directly. + #[cfg(feature = "local-verify")] + pub fn verify_locally( + &self, + expected_report_data: ReportData, + timestamp_seconds: u64, + accepted_measurements: &[ExpectedMeasurements], + ) -> Result { + let report = self.dcap_report(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. Off-chain only. + /// + /// This is the boundary between the DCAP verification (which the contract + /// offloads to the verifier) and the post-DCAP checks + /// ([`verify_with_report`](Self::verify_with_report)). + #[cfg(feature = "local-verify")] + pub fn dcap_report(&self, timestamp_seconds: u64) -> Result { + use crate::dcap_conversions::{IntoDcapType as _, IntoInterfaceType as _}; + + let quote: Vec = self.quote.clone().into_dcap_type(); + let collateral = self.collateral.clone().into_dcap_type(); + Ok( + dcap_qvl::verify::verify("e, &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 +283,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 +306,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 +331,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 +385,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 +528,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 +553,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..77da03a35c 100644 --- a/crates/attestation/src/collateral.rs +++ b/crates/attestation/src/collateral.rs @@ -1,37 +1,34 @@ -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. +//! +//! Re-exported from `tee-verifier-interface` so the collateral type has a +//! single definition shared by the verifier wire, this crate's post-DCAP +//! logic, and every consumer. This crate does not define its own collateral +//! type; the `test-utils` JSON parser below produces the re-exported type. +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. +mod parse { + use super::Collateral; + use alloc::string::String; + use alloc::vec::Vec; + use hex::FromHexError; + use serde_json::Value; + use thiserror::Error; + + /// Parses a JSON value (hex-encoded byte fields) into a [`Collateral`]. + /// + /// The verifier wire [`Collateral`] holds plain `Vec` fields, so this + /// off-chain helper hex-decodes the byte fields explicitly rather than + /// relying on a serde derive — keeping `tee-verifier-interface` serde-free. /// /// # 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 { + /// Returns a [`CollateralError`] if a required field is missing, has the + /// wrong type, or a hex field cannot be decoded. + 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 +44,43 @@ 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`]. + /// Parses a JSON string into 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 { + /// Returns a [`CollateralError`] if the string is not valid JSON, a + /// required field is missing, or a hex field cannot be decoded. + 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..517fcaf33e --- /dev/null +++ b/crates/attestation/src/dcap_conversions.rs @@ -0,0 +1,328 @@ +//! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in +//! `tee-verifier-interface`, for the off-chain `verify_locally` path only. +//! +//! This is an intentional sibling of the identical conversions in the +//! `tee-verifier` contract crate ([`tee-verifier/src/conversions.rs`]). They +//! are NOT shared through a common crate on purpose: sharing would force the +//! deliberately-minimal verifier contract (`dcap-qvl` + interface only) to +//! depend on `attestation` and drag in its `serde`/`serde_json`/`sha2`/ +//! `dstack-sdk-types` closure. Both copies are pinned against `dcap_qvl` drift +//! by their respective byte-equal Borsh-layout tests. +//! +//! Mapped with local [`IntoDcapType`] / [`IntoInterfaceType`] traits because +//! the orphan rule forbids `From`/`Into` impls between two foreign types. + +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, + TcbStatusWithAdvisory, VerifiedReport, +}; + +/// Converts an interface type into its `dcap_qvl` counterpart `T`. +pub(crate) trait IntoDcapType { + fn into_dcap_type(self) -> T; +} + +/// Converts a `dcap_qvl` type into its `tee-verifier-interface` counterpart `T`. +pub(crate) trait IntoInterfaceType { + fn into_interface_type(self) -> T; +} + +impl IntoDcapType for Collateral { + fn into_dcap_type(self) -> dcap_qvl::QuoteCollateralV3 { + dcap_qvl::QuoteCollateralV3 { + 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, + } + } +} + +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, + } + } +} + +/// Converts a `dcap_qvl::QuoteCollateralV3` (e.g. fetched from a PCCS endpoint) +/// into the interface [`Collateral`]. Off-chain helper for callers that hold a +/// `dcap-qvl` collateral and need the wire type. +pub fn collateral_from_dcap(collateral: dcap_qvl::QuoteCollateralV3) -> Collateral { + collateral.into_interface_type() +} + +/// Converts an interface [`Collateral`] into a `dcap_qvl::QuoteCollateralV3`. +/// Off-chain helper, the inverse of [`collateral_from_dcap`]. +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 + } +} + +impl IntoInterfaceType for dq_verify::VerifiedReport { + fn into_interface_type(self) -> VerifiedReport { + VerifiedReport { + status: self.status, + advisory_ids: self.advisory_ids, + report: self.report.into_interface_type(), + ppid: self.ppid, + qe_status: self.qe_status.into_interface_type(), + platform_status: self.platform_status.into_interface_type(), + } + } +} + +impl IntoInterfaceType for dq_quote::Report { + fn into_interface_type(self) -> Report { + match self { + dq_quote::Report::SgxEnclave(r) => Report::SgxEnclave(r.into_interface_type()), + dq_quote::Report::TD10(r) => Report::TD10(r.into_interface_type()), + dq_quote::Report::TD15(r) => Report::TD15(r.into_interface_type()), + } + } +} + +impl IntoInterfaceType for dq_quote::TDReport10 { + fn into_interface_type(self) -> TDReport10 { + TDReport10 { + tee_tcb_svn: self.tee_tcb_svn, + mr_seam: self.mr_seam, + mr_signer_seam: self.mr_signer_seam, + seam_attributes: self.seam_attributes, + td_attributes: self.td_attributes, + xfam: self.xfam, + mr_td: self.mr_td, + mr_config_id: self.mr_config_id, + mr_owner: self.mr_owner, + mr_owner_config: self.mr_owner_config, + rt_mr0: self.rt_mr0, + rt_mr1: self.rt_mr1, + rt_mr2: self.rt_mr2, + rt_mr3: self.rt_mr3, + report_data: self.report_data, + } + } +} + +impl IntoInterfaceType for dq_quote::TDReport15 { + fn into_interface_type(self) -> TDReport15 { + TDReport15 { + base: self.base.into_interface_type(), + tee_tcb_svn2: self.tee_tcb_svn2, + mr_service_td: self.mr_service_td, + } + } +} + +impl IntoInterfaceType for dq_quote::EnclaveReport { + fn into_interface_type(self) -> EnclaveReport { + EnclaveReport { + cpu_svn: self.cpu_svn, + misc_select: self.misc_select, + reserved1: self.reserved1, + attributes: self.attributes, + mr_enclave: self.mr_enclave, + reserved2: self.reserved2, + mr_signer: self.mr_signer, + reserved3: self.reserved3, + isv_prod_id: self.isv_prod_id, + isv_svn: self.isv_svn, + reserved4: self.reserved4, + report_data: self.report_data, + } + } +} + +impl IntoInterfaceType for dq_tcb::TcbStatus { + fn into_interface_type(self) -> TcbStatus { + match self { + dq_tcb::TcbStatus::UpToDate => TcbStatus::UpToDate, + dq_tcb::TcbStatus::OutOfDateConfigurationNeeded => { + TcbStatus::OutOfDateConfigurationNeeded + } + dq_tcb::TcbStatus::OutOfDate => TcbStatus::OutOfDate, + dq_tcb::TcbStatus::ConfigurationAndSWHardeningNeeded => { + TcbStatus::ConfigurationAndSWHardeningNeeded + } + dq_tcb::TcbStatus::ConfigurationNeeded => TcbStatus::ConfigurationNeeded, + dq_tcb::TcbStatus::SWHardeningNeeded => TcbStatus::SWHardeningNeeded, + dq_tcb::TcbStatus::Revoked => TcbStatus::Revoked, + } + } +} + +impl IntoInterfaceType for dq_tcb::TcbStatusWithAdvisory { + fn into_interface_type(self) -> TcbStatusWithAdvisory { + TcbStatusWithAdvisory { + status: self.status.into_interface_type(), + advisory_ids: self.advisory_ids, + } + } +} + +/// Pins the Borsh wire layout of each `tee-verifier-interface` mirror type +/// against its `dcap_qvl` counterpart, so a same-name field/variant reorder in +/// `dcap_qvl` (which the exhaustive conversions above would not catch) diverges +/// the bytes. Mirrors the test in `tee-verifier/src/conversions.rs`; this is +/// the second pin guarding `attestation`'s own conversion copy. +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use rstest::rstest; + + fn assert_same_borsh_bytes( + interface: &I, + dcap: &D, + ) { + let interface_bytes = borsh::to_vec(interface).expect("interface should serialize"); + let dcap_bytes = borsh::to_vec(dcap).expect("dcap should serialize"); + assert_eq!(interface_bytes, dcap_bytes); + } + + fn sample_collateral() -> Collateral { + Collateral { + pck_crl_issuer_chain: "issuer-chain".into(), + root_ca_crl: alloc::vec![1, 2, 3], + pck_crl: alloc::vec![4, 5, 6], + tcb_info_issuer_chain: "tcb-issuer".into(), + tcb_info: "tcb-info-json".into(), + tcb_info_signature: alloc::vec![7, 8], + qe_identity_issuer_chain: "qe-issuer".into(), + qe_identity: "qe-identity-json".into(), + qe_identity_signature: alloc::vec![9, 10], + pck_certificate_chain: Some("pck-chain".into()), + } + } + + fn dcap_td10() -> dq_quote::TDReport10 { + dq_quote::TDReport10 { + tee_tcb_svn: [1; 16], + mr_seam: [2; 48], + mr_signer_seam: [3; 48], + seam_attributes: [4; 8], + td_attributes: [5; 8], + xfam: [6; 8], + mr_td: [7; 48], + mr_config_id: [8; 48], + mr_owner: [9; 48], + mr_owner_config: [10; 48], + rt_mr0: [11; 48], + rt_mr1: [12; 48], + rt_mr2: [13; 48], + rt_mr3: [14; 48], + report_data: [15; 64], + } + } + + fn dcap_td15() -> dq_quote::TDReport15 { + dq_quote::TDReport15 { + base: dcap_td10(), + tee_tcb_svn2: [16; 16], + mr_service_td: [17; 48], + } + } + + fn dcap_sgx() -> dq_quote::EnclaveReport { + dq_quote::EnclaveReport { + cpu_svn: [1; 16], + misc_select: 42, + reserved1: [2; 28], + attributes: [3; 16], + mr_enclave: [4; 32], + reserved2: [5; 32], + mr_signer: [6; 32], + reserved3: [7; 96], + isv_prod_id: 8, + isv_svn: 9, + reserved4: [10; 60], + report_data: [11; 64], + } + } + + fn dcap_verified_report(report: dq_quote::Report) -> dq_verify::VerifiedReport { + dq_verify::VerifiedReport { + status: "UpToDate".into(), + advisory_ids: alloc::vec!["INTEL-SA-00001".into()], + report, + ppid: alloc::vec![0xAB; 16], + qe_status: dq_tcb::TcbStatusWithAdvisory { + status: dq_tcb::TcbStatus::UpToDate, + advisory_ids: alloc::vec![], + }, + platform_status: dq_tcb::TcbStatusWithAdvisory { + status: dq_tcb::TcbStatus::ConfigurationNeeded, + advisory_ids: alloc::vec!["INTEL-SA-00002".into()], + }, + } + } + + #[test] + fn collateral__should_match_dcap_borsh_layout() { + let interface = sample_collateral(); + let dcap = interface.clone().into_dcap_type(); + assert_same_borsh_bytes(&interface, &dcap); + } + + #[test] + fn td_report_10__should_match_dcap_borsh_layout() { + let dcap = dcap_td10(); + let interface: TDReport10 = dcap.into_interface_type(); + assert_same_borsh_bytes(&interface, &dcap); + } + + #[test] + fn td_report_15__should_match_dcap_borsh_layout() { + let dcap = dcap_td15(); + let interface: TDReport15 = dcap.into_interface_type(); + assert_same_borsh_bytes(&interface, &dcap); + } + + #[test] + fn enclave_report__should_match_dcap_borsh_layout() { + let dcap = dcap_sgx(); + let interface: EnclaveReport = dcap.into_interface_type(); + assert_same_borsh_bytes(&interface, &dcap); + } + + #[rstest] + #[case::sgx(dq_quote::Report::SgxEnclave(dcap_sgx()))] + #[case::td10(dq_quote::Report::TD10(dcap_td10()))] + #[case::td15(dq_quote::Report::TD15(dcap_td15()))] + fn report__should_match_dcap_borsh_layout(#[case] dcap: dq_quote::Report) { + let interface: Report = dcap.clone().into_interface_type(); + assert_same_borsh_bytes(&interface, &dcap); + } + + #[rstest] + #[case::verified(dq_quote::Report::TD10(dcap_td10()))] + #[case::sgx(dq_quote::Report::SgxEnclave(dcap_sgx()))] + fn verified_report__should_match_dcap_borsh_layout(#[case] report: dq_quote::Report) { + let dcap = dcap_verified_report(report); + let interface: VerifiedReport = dcap.clone().into_interface_type(); + assert_same_borsh_bytes(&interface, &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..9a5a9c13db 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. This crate does not define its own quote type. +pub use tee_verifier_interface::QuoteBytes; diff --git a/crates/attestation/tests/collateral.rs b/crates/attestation/tests/collateral.rs index e56350f3c7..9d7aa3aed0 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,32 +58,26 @@ 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) + // The signatures are hex-decoded into raw bytes: a 64-byte ECDSA signature + // is 128 hex chars in the JSON. assert_eq!(collateral.tcb_info_signature.len(), 64); - // QE identity signature should be 64 hex chars (32 bytes) assert_eq!(collateral.qe_identity_signature.len(), 64); } #[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 2e403e1718..7dffc0ee77 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -90,7 +90,9 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true } +# TODO(L4): drop `local-verify` once `submit_participant_info` offloads DCAP to +# the `tee-verifier` contract; that removes `dcap-qvl` from the contract WASM. +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 41d904d9da..433bcf7237 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 (L4). + 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, - }) + } } } @@ -311,8 +313,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 (L4). + let Collateral { pck_crl_issuer_chain, root_ca_crl, pck_crl, @@ -323,7 +326,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 e15962aaf1..bc96486748 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -156,10 +156,14 @@ impl TeeState { .into(); let accepted_measurements = self.get_accepted_measurements(); + // TODO(L4): this synchronous DCAP-in-contract path is replaced by the + // cross-contract call to `tee-verifier` + `verify_with_report`, which + // removes `dcap-qvl` from the contract WASM. Until then the contract + // keeps verifying locally so L1 stays behavior-neutral. 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..2b49cf4fe7 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -8,6 +8,10 @@ edition = { workspace = true } abi = ["borsh/unstable__schema", "mpc-primitives/abi", "attestation/borsh-schema"] dstack-conversions = ["attestation/dstack-conversions"] test-utils = ["attestation/test-utils"] +# Off-chain only: enables `Attestation::verify_locally` (full local DCAP + +# post-DCAP verification), forwarding to `attestation/local-verify` which pulls +# in `dcap-qvl`. The contract does not enable this. +local-verify = ["attestation/local-verify"] [dependencies] attestation = { workspace = true } @@ -21,10 +25,16 @@ 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` is used directly by the off-chain `report_data` unit test (gated on +# `local-verify`) to parse a real quote. dcap-qvl = { workspace = true } +# Self-dependency with `local-verify` so the integration tests can exercise the +# full local DCAP + post-DCAP path (`verify_locally`). +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..7c76df3d4a 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; @@ -145,11 +146,22 @@ pub fn default_measurements() -> &'static [ExpectedMeasurements] { } impl Attestation { - /// Verifies the attestation. + /// Verifies the attestation given an already-DCAP-verified report. + /// + /// Pure: no `dcap-qvl`, no host calls. For a `Dstack` attestation the DCAP + /// cryptographic verification is done elsewhere — by the `tee-verifier` + /// contract on-chain, or by [`verify_locally`](Self::verify_locally) + /// off-chain — and its [`VerifiedReport`] is passed in here. This is the + /// function `mpc-contract` calls from its verifier callback. + /// + /// `report` is ignored for `Mock` attestations (they have no real quote); + /// pass any value (e.g. a default) — callers that hold a `Mock` should use + /// the synchronous path rather than going through the verifier. /// /// On success, returns an [`AcceptedAttestation`]. - pub fn verify( + pub fn verify_with_report( &self, + report: &VerifiedReport, expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], @@ -158,89 +170,181 @@ impl Attestation { ) -> 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, + let (mpc_image_hash, launcher_compose_hash) = verify_dstack_mpc_hashes( + dstack_attestation, + allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, )?; let AcceptedDstackAttestation { measurements, advisory_ids, - } = dstack_attestation.verify( + } = dstack_attestation.verify_with_report( + 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, - }), + Ok(accepted_dstack_attestation( + mpc_image_hash, + launcher_compose_hash, + measurements, advisory_ids, - }) + current_timestamp_seconds, + )) } - Self::Mock(mock_attestation) => { - // Override attestation verification for this case - let () = verify_mock_attestation( - mock_attestation, + Self::Mock(mock_attestation) => verify_mock( + mock_attestation, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + current_timestamp_seconds, + ), + } + } + + /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then + /// the post-DCAP checks. Off-chain only (the `local-verify` feature pulls + /// in `dcap-qvl`). + /// + /// Used by the node, `tee-authority`, and `attestation-cli`. On-chain, + /// `mpc-contract` instead calls the verifier contract for DCAP and then + /// [`verify_with_report`](Self::verify_with_report). + #[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 (mpc_image_hash, launcher_compose_hash) = verify_dstack_mpc_hashes( + dstack_attestation, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, - accepted_measurements, + )?; + + let AcceptedDstackAttestation { + measurements, + advisory_ids, + } = dstack_attestation.verify_locally( + expected_report_data, current_timestamp_seconds, + accepted_measurements, )?; - Ok(AcceptedAttestation { - attestation: VerifiedAttestation::Mock(mock_attestation.clone()), - advisory_ids: Vec::new(), - }) + Ok(accepted_dstack_attestation( + mpc_image_hash, + launcher_compose_hash, + measurements, + advisory_ids, + current_timestamp_seconds, + )) } + Self::Mock(mock_attestation) => verify_mock( + mock_attestation, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + current_timestamp_seconds, + ), } } } +/// Extracts and allowlist-checks the MPC image hash and launcher compose hash +/// from a `Dstack` attestation's TCB info / app-compose. Independent of the +/// DCAP report, so shared by both verification entry points. +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)) +} + +/// Assembles the [`AcceptedAttestation`] for a verified `Dstack` attestation, +/// stamping the expiry. Shared by both verification entry points. +fn accepted_dstack_attestation( + mpc_image_hash: NodeImageHash, + launcher_compose_hash: LauncherDockerComposeHash, + measurements: ExpectedMeasurements, + advisory_ids: Vec, + current_timestamp_seconds: u64, +) -> AcceptedAttestation { + // TODO(#1639): extract timestamp from certificate itself + let expiration_timestamp_seconds = + current_timestamp_seconds + DEFAULT_EXPIRATION_DURATION_SECONDS; + AcceptedAttestation { + attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash, + launcher_compose_hash, + expiry_timestamp_seconds: expiration_timestamp_seconds, + measurements, + }), + advisory_ids, + } +} + +/// Verifies a `Mock` attestation. No DCAP, so identical on both entry points. +fn verify_mock( + mock_attestation: &MockAttestation, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + current_timestamp_seconds: u64, +) -> Result { + 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(), + }) +} + /// Verifies MPC node image hash is in allowed list. fn verify_mpc_hash( image_hash: &NodeImageHash, 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..b5f22b9f74 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}; + // 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(); + use alloc::vec::Vec; + use dcap_qvl::quote::Quote; + use test_utils::attestation::quote; + + let valid_quote: Vec = quote().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..e0e1f277a5 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,11 +43,67 @@ 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) ); } +/// `verify_locally` (DCAP + post-DCAP) and `verify_with_report` (post-DCAP +/// against a supplied report) must agree: the contract feeds the verifier's +/// report into `verify_with_report`, so it must yield exactly what a full local +/// verify would. This runs DCAP once to obtain the report, then compares. +#[test] +fn verify_with_report_agrees_with_verify_locally() { + use mpc_attestation::attestation::Attestation; + + let attestation = mock_dstack_attestation(); + let tls_key = p2p_tls_key(); + let account_key = account_key(); + let report_data: ReportData = ReportDataV1::new(tls_key, account_key).into(); + let timestamp_s = VALID_ATTESTATION_TIMESTAMP; + let allowed_mpc_hashes = [image_digest()]; + let allowed_launcher_hashes = [launcher_compose_digest()]; + + // Full local verify (DCAP + post-DCAP). + let local = attestation + .verify_locally( + report_data.clone().into(), + timestamp_s, + &allowed_mpc_hashes, + &allowed_launcher_hashes, + default_measurements(), + ) + .expect("local verify should succeed"); + + // Obtain the report the verifier contract would return (DCAP only), then + // feed it to the pure post-DCAP path the contract uses. + let Attestation::Dstack(dstack) = &attestation else { + panic!("fixture is a Dstack attestation"); + }; + let report = dstack + .dcap_report(timestamp_s) + .expect("dcap report should be produced"); + + let with_report = attestation + .verify_with_report( + &report, + report_data.into(), + timestamp_s, + &allowed_mpc_hashes, + &allowed_launcher_hashes, + default_measurements(), + ) + .expect("verify_with_report should succeed"); + + // `VerifiedAttestation` has no `PartialEq`; compare via its Borsh encoding, + // which is the form actually stored on-chain. + assert_eq!( + borsh::to_vec(&local.attestation).unwrap(), + borsh::to_vec(&with_report.attestation).unwrap(), + ); + assert_eq!(local.advisory_ids, with_report.advisory_ids); +} + #[test] fn validated_dstack_attestation_can_be_reverified() { // given @@ -56,7 +116,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 +150,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 +183,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 +204,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 +243,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 +287,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 +321,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 9d47869d48..1175183e58 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -36,7 +36,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..319f6f207f 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 (L4). + 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..70aaaff326 100644 --- a/crates/tee-authority/src/tee_authority.rs +++ b/crates/tee-authority/src/tee_authority.rs @@ -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-interface/Cargo.toml b/crates/tee-verifier-interface/Cargo.toml index 6316ec070f..55003925a5 100644 --- a/crates/tee-verifier-interface/Cargo.toml +++ b/crates/tee-verifier-interface/Cargo.toml @@ -6,10 +6,22 @@ edition = { workspace = true } [features] borsh-schema = ["borsh/unstable__schema"] +# Off by default. Derives serde only on the verifier *input* types +# (`QuoteBytes`, `Collateral`) for MPC-internal off-chain callers that embed +# them in a serde struct (e.g. the node's `/public_data` HTTP payload). +# External teams and the verifier contract talk Borsh-only and never enable 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..999601fd20 100644 --- a/crates/tee-verifier-interface/src/lib.rs +++ b/crates/tee-verifier-interface/src/lib.rs @@ -43,6 +43,14 @@ use borsh::{BorshDeserialize, BorshSerialize}; derive_more::Into, )] #[cfg_attr(feature = "borsh-schema", derive(borsh::BorshSchema))] +// The `serde` feature is off by default and exists only for MPC-internal +// off-chain callers (e.g. the node's HTTP `/public_data` payload) that embed +// `QuoteBytes` in a serde struct. External teams and the verifier contract, +// which talk to the verifier only over the Borsh cross-contract ABI, never +// enable it and never compile serde. Only the verifier *input* types +// (`QuoteBytes`, `Collateral`) carry 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 +59,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 +// MPC-internal 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/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 5cac9b4f28..fe12b0417b 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -65,9 +65,12 @@ 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"); + // `quote.json` is a JSON array of byte integers. The verifier wire + // `QuoteBytes` is Borsh-only (no serde), so parse to `Vec` and wrap. + 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 +101,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(); From 2bdfcf1c9a5897b3880d9d66e40e988b6d8df3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 18:48:01 +0200 Subject: [PATCH 02/21] feat(attestation): lower attestation expiry window from 7 days to 1 day Bounds how long a wrongly-accepted attestation (e.g. one let through by a since-rotated, buggy verifier) stays trusted before it ages out via re_verify, without any sweep. The window stays far above the node's hourly resubmission cadence (ATTESTATION_RESUBMISSION_INTERVAL = 1h), so honest nodes refresh with comfortable margin. The node's storage-time heuristic in tx_sender reads the same constant and the checked_sub stays safe. Decouples the tee-authority MAX_COLLATERAL_AGE doc note, which no longer needs to match this window. --- crates/mpc-attestation/src/attestation.rs | 8 +++++++- crates/tee-authority/src/tee_authority.rs | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 7c76df3d4a..d4007557b3 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -22,7 +22,13 @@ use crate::alloc::format; use crate::alloc::string::{String, ToString}; // TODO(#1639): extract timestamp from certificate itself -pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24 * 7; // 7 days +// +// 1 day (lowered from 7) bounds how long a wrongly-accepted attestation — e.g. +// one a since-rotated, buggy verifier let through — stays trusted before it +// ages out via `re_verify`, without a sweep. The window stays well above the +// node's hourly `periodic_attestation_submission` cadence, so honest nodes +// refresh with comfortable margin. +pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24; // 1 day #[expect(clippy::large_enum_variant)] #[derive(Clone, Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] diff --git a/crates/tee-authority/src/tee_authority.rs b/crates/tee-authority/src/tee_authority.rs index 70aaaff326..e0849e149e 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`, now 1 day); 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`, From 1c3129234abf59b5e16f8e60b588a1f205879390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 19:07:42 +0200 Subject: [PATCH 03/21] feat(contract): add trusted-verifier state and participant voting (dormant) Introduces the contract state and governance for the upcoming async attestation flow, without yet routing submit_participant_info through it: - New MpcContract fields: tee_verifier_account_id (the trusted verifier the contract will call verify_quote on), tee_verifier_votes, and an empty pending_attestations map. New append-only storage keys for each. - vote_tee_verifier_change(candidate, expected_code_hash) and withdraw_tee_verifier_vote, mirroring the foreign-chain provider vote on the generic Votes primitive. Crossing threshold sets tee_verifier_account_id and clears the round; expected_code_hash binds each yes-vote to audited code. - Post-resharing sweep of stale verifier votes via clean_foreign_chain_data. - Migration starts deployed contracts from a placeholder verifier account; participants vote in a real one. Fresh deploys may set it via InitConfig. - PendingAttestation / FinalOutcome types for the later async flow. Unit-tested: vote threshold crossing, same-account-different-hash isolation, re-vote replacement, withdraw, post-reshare retain, and borsh round-trips. The contract ABI snapshot and sandbox migration/upgrade tests must be regenerated in CI (the WASM build requires the contract toolchain). --- crates/contract/src/lib.rs | 117 ++++++++ crates/contract/src/storage_keys.rs | 3 + crates/contract/src/tee.rs | 2 + .../contract/src/tee/pending_attestation.rs | 59 ++++ crates/contract/src/tee/verifier_votes.rs | 277 ++++++++++++++++++ crates/contract/src/v3_11_2_state.rs | 10 +- .../tests/sandbox/contract_configuration.rs | 3 + .../src/method_names.rs | 2 + .../src/types/config.rs | 7 + 9 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 crates/contract/src/tee/pending_attestation.rs create mode 100644 crates/contract/src/tee/verifier_votes.rs diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 39cb51b863..87e99633fb 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -82,7 +82,9 @@ use primitives::{ thresholds::{Threshold, ThresholdParameters}, }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; +use tee::pending_attestation::PendingAttestation; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; +use tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ @@ -104,6 +106,24 @@ const MINIMUM_CKD_REQUEST_DEPOSIT: NearToken = NearToken::from_yoctonear(1); /// callers may pick a different value; this only governs the automatic invocation. const RESHARE_CLEAN_INVALID_ATTESTATIONS_MAX_SCAN: u32 = 100; +/// Placeholder `tee_verifier_account_id` used when no real verifier has been +/// chosen yet (fresh `init` without one, or migration from a pre-verifier +/// contract). It is not a deployed contract; `Dstack` `submit_participant_info` +/// is rejected until participants vote in a real verifier via +/// `vote_tee_verifier_change`. +const UNSET_TEE_VERIFIER_ACCOUNT: &str = "unset.tee-verifier.invalid"; + +/// The `tee_verifier_account_id` to start from, given an optional value +/// supplied at init time. Falls back to the [`UNSET_TEE_VERIFIER_ACCOUNT`] +/// placeholder. +pub(crate) fn initial_tee_verifier_account_id(configured: Option) -> AccountId { + configured.unwrap_or_else(|| { + UNSET_TEE_VERIFIER_ACCOUNT + .parse() + .expect("placeholder verifier account id must be valid") + }) +} + /// Checks that the caller attached at least `minimum_deposit` and refunds any excess. /// /// A non-zero deposit is required so that the transaction must be signed by a @@ -159,6 +179,17 @@ pub struct MpcContract { // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chain_rpc_whitelist: ForeignChainRpcWhitelist, + /// The locked account `mpc-contract` trusts as the TEE quote verifier. + /// `submit_participant_info` calls `verify_quote` on it. Mutated only by a + /// threshold `vote_tee_verifier_change`; the mutation re-routes future + /// submissions and never touches already-stored attestations. + tee_verifier_account_id: AccountId, + /// Pending participant votes for changing `tee_verifier_account_id`. + tee_verifier_votes: TeeVerifierVotes, + /// In-flight `Dstack` attestation verifications, keyed by submitter. Holds + /// the yield handle and the data the resolution callback needs. (Consumed + /// by the async `submit_participant_info` flow in a later step.) + pending_attestations: LookupMap, } #[near(serializers=[borsh])] @@ -1530,6 +1561,72 @@ impl MpcContract { Ok(applied) } + /// Vote for `(candidate_account_id, expected_code_hash)` as the trusted TEE + /// verifier. Re-voting from the same caller replaces the previous vote; see + /// [`Self::withdraw_tee_verifier_vote`] to withdraw without replacing. When + /// the threshold is reached, `tee_verifier_account_id` is updated and all + /// pending verifier votes are cleared. Every subsequent + /// `submit_participant_info` is then verified by the new verifier; entries + /// the previous verifier produced are not purged — they age out via the + /// attestation expiration window enforced in `re_verify`. + /// + /// `expected_code_hash` binds each yes-vote to the code the voter audited + /// off-chain; voters who name the same account with different hashes land in + /// different buckets and neither reaches threshold alone. + #[handle_result] + pub fn vote_tee_verifier_change( + &mut self, + candidate_account_id: AccountId, + expected_code_hash: CryptoHash, + ) -> Result<(), Error> { + log!( + "vote_tee_verifier_change: signer={}, candidate={}, expected_code_hash={}", + env::signer_account_id(), + candidate_account_id, + hex::encode(expected_code_hash), + ); + self.voter_or_panic(); + + let threshold_parameters = self + .protocol_state + .threshold_parameters() + .expect("voter_or_panic() above already errors on NotInitialized"); + let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; + + let proposal = VerifierChangeProposal { + candidate_account_id, + expected_code_hash, + }; + if let Some(new_verifier) = + self.tee_verifier_votes + .vote(proposal, participant, threshold_parameters)? + { + log!("vote_tee_verifier_change: new verifier = {}", new_verifier); + self.tee_verifier_account_id = new_verifier; + } + Ok(()) + } + + /// Withdraw the caller's current vote on any pending verifier-change + /// proposal, if any. No-op if the caller has not voted. + #[handle_result] + pub fn withdraw_tee_verifier_vote(&mut self) -> Result<(), Error> { + log!( + "withdraw_tee_verifier_vote: signer={}", + env::signer_account_id(), + ); + self.voter_or_panic(); + + let threshold_parameters = self + .protocol_state + .threshold_parameters() + .expect("voter_or_panic() above already errors on NotInitialized"); + let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; + + self.tee_verifier_votes.withdraw(&participant); + Ok(()) + } + /// On-chain RPC provider whitelist keyed by `ForeignChain`. Nodes read this at /// startup to validate their local `foreign_chains.yaml`. Borsh-encoded result. #[result_serializer(borsh)] @@ -1723,6 +1820,9 @@ impl MpcContract { } self.foreign_chain_rpc_whitelist.votes.retain(participants); + // Drop verifier-change votes from accounts that lost participant status, + // same as the foreign-chain provider votes above. + self.tee_verifier_votes.retain(participants); Ok(()) } @@ -1767,6 +1867,13 @@ impl MpcContract { StorageKey::PendingVerifyForeignTxRequestsV2, ), proposed_updates: ProposedUpdates::default(), + tee_verifier_account_id: initial_tee_verifier_account_id( + init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()), + ), + tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), config: init_config.map(Into::into).unwrap_or_default(), tee_state, accept_requests: true, @@ -1823,6 +1930,13 @@ impl MpcContract { let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); Ok(MpcContract { + tee_verifier_account_id: initial_tee_verifier_account_id( + init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()), + ), + tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), config: init_config.map(Into::into).unwrap_or_default(), protocol_state: ProtocolContractState::Running(RunningContractState::new( domains, @@ -4052,6 +4166,9 @@ mod tests { node_migrations: Default::default(), metrics: Default::default(), foreign_chain_rpc_whitelist: Default::default(), + tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_votes: Default::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), } } } diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 7137f6b70d..dcd66b50ce 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -30,4 +30,7 @@ pub enum StorageKey { AllowedForeignChainProvidersV1, ForeignChainProviderVotesByVoterV1, ForeignChainProviderVotesByProposalV1, + TeeVerifierVotesByVoterV1, + TeeVerifierVotesByProposalV1, + PendingAttestationsV1, } diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 1a6285a307..310f91b910 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -1,5 +1,7 @@ pub mod measurements; +pub mod pending_attestation; pub mod proposal; pub mod tee_state; #[cfg(any(test, feature = "test-utils"))] pub mod test_utils; +pub mod verifier_votes; diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs new file mode 100644 index 0000000000..0c78d45daa --- /dev/null +++ b/crates/contract/src/tee/pending_attestation.rs @@ -0,0 +1,59 @@ +//! State for an in-flight `Dstack` attestation submission. +//! +//! `submit_participant_info` for a `Dstack` attestation is asynchronous: it +//! yields, fires a cross-contract `verify_quote` call to the trusted verifier, +//! and resumes from the response callback. Everything the callback needs that +//! is not re-readable from contract state at callback time is stashed here, +//! keyed by the submitter's `AccountId`, until the verification resolves (or +//! the yield times out). +//! +//! Used in a later step (the async `submit_participant_info` flip); defined +//! here so the state field and storage key land first. + +use mpc_attestation::attestation::DstackAttestation; +use near_mpc_contract_interface::types::Ed25519PublicKey; +use near_sdk::{CryptoHash, NearToken, near}; + +/// One in-flight verification per submitter account. +#[near(serializers=[borsh])] +#[derive(Debug)] +pub struct PendingAttestation { + /// The submitted `Dstack` payload — RTMR3 event log, app-compose, and the + /// quote/collateral — that the post-DCAP checks consume once the verifier + /// returns its report. + pub dstack: DstackAttestation, + /// The submitter's TLS public key, hashed with its account public key and + /// compared against the quote's report-data during the post-DCAP checks. + pub tls_public_key: Ed25519PublicKey, + /// Deposit attached at submit time. `env::attached_deposit()` is not visible + /// from the callback receipt, so it is stashed here: consumed for storage + /// staking on success, refunded on failure. + pub attached_deposit: NearToken, + /// Yield handle from `env::promise_yield_create`. The resolution callback + /// reads it back to `promise_yield_resume` with the final outcome. + pub data_id: CryptoHash, +} + +/// Outcome the resolution callback resumes the yielded promise with. The +/// yield-callback maps it back to a `Result` for the original caller. +#[near(serializers=[borsh])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinalOutcome { + Ok, + Err(String), +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + + #[test] + fn final_outcome__should_round_trip_borsh() { + for original in [FinalOutcome::Ok, FinalOutcome::Err("rejected".to_string())] { + let bytes = borsh::to_vec(&original).expect("serialize"); + let decoded: FinalOutcome = borsh::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); + } + } +} diff --git a/crates/contract/src/tee/verifier_votes.rs b/crates/contract/src/tee/verifier_votes.rs new file mode 100644 index 0000000000..e76ad88a46 --- /dev/null +++ b/crates/contract/src/tee/verifier_votes.rs @@ -0,0 +1,277 @@ +//! Participant voting for the trusted `tee-verifier` account. +//! +//! `mpc-contract` invokes `verify_quote` on a single trusted verifier account +//! (`tee_verifier_account_id`). Which account that is is decided by a threshold +//! vote of active participants, each committing to the `(account_id, code_hash)` +//! pair they audited off-chain. This mirrors the foreign-chain provider voting +//! ([`crate::foreign_chain_rpc::ProviderVotes`]) on top of the generic +//! [`Votes`] primitive. + +use crate::errors::{ConversionError, Error}; +use crate::primitives::thresholds::ThresholdParameters; +use crate::primitives::votes::{ProposalHash, ProposalHashEncoding, Votes}; +use crate::primitives::{key_state::AuthenticatedParticipantId, participants::Participants}; +use crate::storage_keys::StorageKey; +use near_sdk::{AccountId, CryptoHash, near}; + +/// A proposal to point `tee_verifier_account_id` at `candidate_account_id`. +/// +/// `expected_code_hash` makes every yes-voter commit to the exact code they +/// audited off-chain: two voters who name the same account but disagree on its +/// code hash land in different proposal buckets and neither reaches threshold +/// on its own. The contract consumes only `candidate_account_id` once a bucket +/// crosses threshold; the hash is purely a commitment device. +#[near(serializers = [borsh])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifierChangeProposal { + pub candidate_account_id: AccountId, + pub expected_code_hash: CryptoHash, +} + +impl ProposalHashEncoding for VerifierChangeProposal { + fn bytes_for_hash(&self) -> Vec { + borsh::to_vec(self).expect("borsh serialization of VerifierChangeProposal must succeed") + } +} + +/// Pending votes for changing `tee_verifier_account_id`. Each voter is an +/// active MPC participant authenticated via [`AuthenticatedParticipantId`]. +#[near(serializers=[borsh])] +#[derive(Debug)] +pub struct TeeVerifierVotes { + pending: Votes, +} + +impl Default for TeeVerifierVotes { + fn default() -> Self { + Self { + pending: Votes::new( + StorageKey::TeeVerifierVotesByVoterV1, + StorageKey::TeeVerifierVotesByProposalV1, + ), + } + } +} + +impl TeeVerifierVotes { + /// Records `participant`'s vote for `proposal`. Returns `Some(candidate)` + /// when the proposal crosses the signing threshold (stale rows from dropped + /// participants don't count); on `Some`, all pending rows for that + /// candidate are cleared and the caller must apply the new + /// `tee_verifier_account_id`. + pub fn vote( + &mut self, + proposal: VerifierChangeProposal, + participant: AuthenticatedParticipantId, + threshold_parameters: &ThresholdParameters, + ) -> Result, Error> { + let protocol_threshold = threshold_parameters.threshold().value(); + let participants = threshold_parameters.participants(); + let proposal_hash: ProposalHash = proposal.clone().into(); + + let count_usize = { + let voter_set = self.pending.vote(participant, proposal_hash); + voter_set.count_for(|p| participants.is_participant_given_participant_id(&p.get())) + }; + let count = u64::try_from(count_usize).map_err(|e| ConversionError::DataConversion { + reason: format!("vote count {count_usize} does not fit in u64: {e}"), + })?; + + if count >= protocol_threshold { + // The candidate is now trusted, so this voting round is over. Clear + // every pending vote — including losing-hash buckets for the same + // account and any votes for other candidates — so a stale quorum + // can't later re-fire against the now-current verifier. There is at + // most one trusted verifier; the next change starts a fresh round. + self.pending.clear(); + Ok(Some(proposal.candidate_account_id)) + } else { + Ok(None) + } + } + + /// Withdraws the caller's current vote, if any. No-op when the caller has + /// not voted. + pub fn withdraw(&mut self, participant: &AuthenticatedParticipantId) { + self.pending.remove_vote(participant); + } + + /// Drops votes from accounts that are no longer participants (called after + /// a resharing changes the participant set). + pub fn retain(&mut self, current: &Participants) { + self.pending + .retain_votes(|p| current.is_participant_given_participant_id(&p.get())); + } + + #[cfg(test)] + fn pending_voter_count(&self) -> usize { + self.pending.all().values().map(|s| s.len()).sum() + } +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use crate::primitives::test_utils::gen_participants; + use crate::primitives::thresholds::ThresholdParameters; + use mpc_primitives::Threshold; + use near_sdk::test_utils::VMContextBuilder; + use near_sdk::testing_env; + + fn tp(participants: &Participants, n: u64) -> ThresholdParameters { + ThresholdParameters::new_unvalidated(participants.clone(), Threshold::new(n)) + } + + /// Build `n` participants and pre-authenticate each (env reset before any + /// storage-backed state is touched, mirroring the foreign-chain vote tests). + fn setup(n: usize) -> (Participants, Vec) { + let participants = gen_participants(n); + let mut auth_ids = Vec::with_capacity(n); + for (account_id, _, _) in participants.participants() { + let mut ctx = VMContextBuilder::new(); + ctx.signer_account_id(account_id.clone()); + testing_env!(ctx.build()); + auth_ids.push(AuthenticatedParticipantId::new(&participants).unwrap()); + } + (participants, auth_ids) + } + + fn candidate(id: &str) -> AccountId { + id.parse().unwrap() + } + + fn proposal(account: &str, hash_byte: u8) -> VerifierChangeProposal { + VerifierChangeProposal { + candidate_account_id: candidate(account), + expected_code_hash: [hash_byte; 32], + } + } + + #[test] + fn vote__should_not_cross_below_threshold() { + // Given 3 participants, threshold 2 + let (participants, voters) = setup(3); + let params = tp(&participants, 2); + let mut votes = TeeVerifierVotes::default(); + + // When one participant votes + let result = votes + .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .unwrap(); + + // Then no candidate wins yet + assert_eq!(result, None); + assert_eq!(votes.pending_voter_count(), 1); + } + + #[test] + fn vote__should_cross_threshold_and_clear_pending() { + // Given 3 participants, threshold 2 + let (participants, voters) = setup(3); + let params = tp(&participants, 2); + let mut votes = TeeVerifierVotes::default(); + + // When two participants vote for the same (account, hash) + assert_eq!( + votes + .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .unwrap(), + None + ); + let result = votes + .vote(proposal("v.near", 1), voters[1].clone(), ¶ms) + .unwrap(); + + // Then the candidate wins and all pending votes are cleared + assert_eq!(result, Some(candidate("v.near"))); + assert_eq!(votes.pending_voter_count(), 0); + } + + #[test] + fn vote__should_not_combine_same_account_different_hashes() { + // Given 3 participants, threshold 2 + let (participants, voters) = setup(3); + let params = tp(&participants, 2); + let mut votes = TeeVerifierVotes::default(); + + // When two participants vote for the same account but different code hashes + assert_eq!( + votes + .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .unwrap(), + None + ); + let result = votes + .vote(proposal("v.near", 2), voters[1].clone(), ¶ms) + .unwrap(); + + // Then neither bucket reaches threshold + assert_eq!(result, None); + assert_eq!(votes.pending_voter_count(), 2); + } + + #[test] + fn revote__should_replace_previous_vote() { + let (participants, voters) = setup(3); + let params = tp(&participants, 2); + let mut votes = TeeVerifierVotes::default(); + + votes + .vote(proposal("a.near", 1), voters[0].clone(), ¶ms) + .unwrap(); + // Same voter switches to a different candidate. + votes + .vote(proposal("b.near", 1), voters[0].clone(), ¶ms) + .unwrap(); + + // Still just one pending vote, now for b.near; a second voter on b.near crosses. + assert_eq!(votes.pending_voter_count(), 1); + let result = votes + .vote(proposal("b.near", 1), voters[1].clone(), ¶ms) + .unwrap(); + assert_eq!(result, Some(candidate("b.near"))); + } + + #[test] + fn withdraw__should_remove_caller_vote() { + let (participants, voters) = setup(3); + let params = tp(&participants, 2); + let mut votes = TeeVerifierVotes::default(); + + votes + .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .unwrap(); + assert_eq!(votes.pending_voter_count(), 1); + + votes.withdraw(&voters[0]); + assert_eq!(votes.pending_voter_count(), 0); + + // No-op for a voter who never voted. + votes.withdraw(&voters[1]); + assert_eq!(votes.pending_voter_count(), 0); + } + + #[test] + fn retain__should_keep_current_participants_and_drop_the_rest() { + let (participants, voters) = setup(3); + let params = tp(&participants, 3); + let mut votes = TeeVerifierVotes::default(); + + votes + .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .unwrap(); + votes + .vote(proposal("v.near", 1), voters[1].clone(), ¶ms) + .unwrap(); + assert_eq!(votes.pending_voter_count(), 2); + + // Retaining against the same participant set is a no-op. + votes.retain(&participants); + assert_eq!(votes.pending_voter_count(), 2); + + // Retaining against an empty set (no current participants) drops all votes. + votes.retain(&gen_participants(0)); + assert_eq!(votes.pending_voter_count(), 0); + } +} diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 088f07a2d0..9ea7c7fbe1 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -14,13 +14,15 @@ use near_sdk::{env, store::LookupMap}; use crate::{ Config, SupportedForeignChainsByNode, foreign_chain_rpc::ForeignChainRpcWhitelist, + initial_tee_verifier_account_id, node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, signature::{SignatureRequest, YieldIndex}, }, state::ProtocolContractState, - tee::tee_state::TeeState, + storage_keys::StorageKey, + tee::{tee_state::TeeState, verifier_votes::TeeVerifierVotes}, update::ProposedUpdates, }; @@ -62,6 +64,12 @@ impl From for crate::MpcContract { node_migrations: old.node_migrations, metrics: old.metrics, foreign_chain_rpc_whitelist: old.foreign_chain_rpc_whitelist, + // No verifier was chosen by the pre-verifier contract: start from the + // placeholder, empty votes, and an empty pending map. Participants + // vote in a real verifier via `vote_tee_verifier_change`. + tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestationsV1), } } } diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 4621e7d95c..2f5be02cfb 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -102,6 +102,9 @@ async fn contract_configuration_can_be_set_on_initialization() { cleanup_orphaned_node_migrations_tera_gas: Some(11), remove_non_participant_update_votes_tera_gas: Some(12), clean_foreign_chain_data_tera_gas: Some(13), + // `tee_verifier_account_id` is not part of `Config`, so the `config()` + // view round-trips it as `None`; keep it `None` here so the assertion holds. + tee_verifier_account_id: None, }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index d234c025cc..a551cf721e 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -29,6 +29,8 @@ pub const VOTE_CANCEL_RESHARING: &str = "vote_cancel_resharing"; pub const VOTE_ABORT_KEY_EVENT_INSTANCE: &str = "vote_abort_key_event_instance"; pub const VOTE_UPDATE: &str = "vote_update"; pub const VOTE_UPDATE_FOREIGN_CHAIN_PROVIDERS: &str = "vote_update_foreign_chain_providers"; +pub const VOTE_TEE_VERIFIER_CHANGE: &str = "vote_tee_verifier_change"; +pub const WITHDRAW_TEE_VERIFIER_VOTE: &str = "withdraw_tee_verifier_vote"; pub const REMOVE_UPDATE_VOTE: &str = "remove_update_vote"; pub const REMOVE_NON_PARTICIPANT_UPDATE_VOTES: &str = "remove_non_participant_update_votes"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index af4284dcb5..150cb6306f 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -47,6 +47,11 @@ pub struct InitConfig { pub remove_non_participant_update_votes_tera_gas: Option, /// Prepaid gas for a `clean_foreign_chain_data` call. pub clean_foreign_chain_data_tera_gas: Option, + /// The account whose `verify_quote` method the contract trusts for TEE + /// attestation verification. Fresh deploys may set it here; otherwise the + /// contract starts with a placeholder and participants vote one in via + /// `vote_tee_verifier_change`. + pub tee_verifier_account_id: Option, } /// Configuration parameters of the contract. @@ -118,6 +123,7 @@ mod tests { cleanup_orphaned_node_migrations_tera_gas: Some(3), remove_non_participant_update_votes_tera_gas: Some(5), clean_foreign_chain_data_tera_gas: Some(5), + tee_verifier_account_id: Some("verifier.near".parse().unwrap()), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -167,6 +173,7 @@ mod tests { cleanup_orphaned_node_migrations_tera_gas: None, remove_non_participant_update_votes_tera_gas: None, clean_foreign_chain_data_tera_gas: None, + tee_verifier_account_id: None, }; assert_eq!(default_config, config_with_all_values_as_none); From 097f17730f9ad01875ea5d463349054374cd43c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 19:43:18 +0200 Subject: [PATCH 04/21] feat(contract): verify Dstack attestations via the tee-verifier contract submit_participant_info no longer runs dcap_qvl in the contract WASM. Mock attestations are still verified synchronously (no DCAP); Dstack attestations now go async: - submit_participant_info returns PromiseOrValue<()>. The Dstack arm rejects a duplicate in-flight submission and an unset (placeholder) verifier, stashes a PendingAttestation, registers a yield, and fires a cross-contract verify_quote call whose .then bridges into resolve_verification. - resolve_verification owns the answered outcomes: Verified runs the post-DCAP checks (via Attestation::verify_with_report) against fresh policy and stores + resumes Ok, or refunds + resumes Err on a post-DCAP failure; Rejected refunds + resumes Err immediately; a non-answer logs and defers to the yield timeout. - on_attestation_verified is the trivial yield-callback; its timeout branch does the deferred cleanup + refund. - tee_state gains add_mock_participant / finish_dstack_verify / store_verified_ attestation (split from add_participant, which is now a test-only Mock shim). - Storage charging is preserved (charge only when new or non-participant; actual delta; refund excess) and relocated into the verified path; the deposit and participant flag are stashed in PendingAttestation. - New Config gas knobs for the verifier call and the two callbacks. dcap-qvl is now absent from mpc-contract's normal dependency graph. The Dstack end-to-end paths move to sandbox tests (added next); Mock paths and the post-DCAP logic stay unit-tested. ABI snapshot + sandbox tests regenerate in CI. --- Cargo.lock | 2 + crates/contract/Cargo.toml | 6 +- crates/contract/src/config.rs | 21 + crates/contract/src/dto_mapping.rs | 15 + crates/contract/src/errors.rs | 8 + crates/contract/src/lib.rs | 604 +++++++++--------- .../contract/src/tee/pending_attestation.rs | 6 + crates/contract/src/tee/tee_state.rs | 95 ++- .../tests/inprocess/attestation_submission.rs | 14 +- .../tests/sandbox/contract_configuration.rs | 3 + .../sandbox/upgrade_from_current_contract.rs | 3 + crates/mpc-attestation/src/attestation.rs | 27 + .../src/method_names.rs | 5 + .../src/types/config.rs | 18 + crates/test-utils/src/contract_types.rs | 3 + 15 files changed, 507 insertions(+), 323 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40354e16c3..80da90d532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5769,6 +5769,7 @@ version = "3.11.0" dependencies = [ "anyhow", "assert_matches", + "attestation", "blstrs", "borsh", "cargo-near-build", @@ -5803,6 +5804,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "signature", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", "threshold-signatures", diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 7dffc0ee77..32342416b9 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -77,6 +77,7 @@ __abi-generate = ["abi", "near-sdk/__abi-generate"] [dependencies] assert_matches = { workspace = true } +attestation = { workspace = true } blstrs = { workspace = true } borsh = { workspace = true } curve25519-dalek = { workspace = true } @@ -90,9 +91,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -# TODO(L4): drop `local-verify` once `submit_participant_info` offloads DCAP to -# the `tee-verifier` contract; that removes `dcap-qvl` from the contract WASM. -mpc-attestation = { workspace = true, features = ["local-verify"] } +mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } @@ -107,6 +106,7 @@ rand = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +tee-verifier-interface = { workspace = true } thiserror = { workspace = true } threshold-signatures = { workspace = true, optional = true } diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index c1a06aef25..fb132a0dfd 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -32,6 +32,18 @@ const DEFAULT_CLEANUP_ORPHANED_NODE_MIGRATIONS_TERA_GAS: u64 = 4; const DEFAULT_REMOVE_NON_PARTICIPANT_UPDATE_VOTES_TERA_GAS: u64 = 5; /// Prepaid gas for a `clean_foreign_chain_data` call const DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS: u64 = 5; +/// Gas attached to the cross-contract `verify_quote` call on the TEE verifier. +/// Dominated by ECDSA verifications + X.509 chain walking inside +/// `dcap_qvl::verify::verify`. TODO(#3265): benchmark. +const DEFAULT_VERIFIER_TERA_GAS: u64 = 100; +/// Prepaid gas for the `resolve_verification` callback. Carries the bulk of the +/// contract-side work (post-DCAP checks + `stored_attestations` insert), so it +/// gets the largest budget. TODO(#3265): benchmark. +const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; +/// Prepaid gas for the `on_attestation_verified` yield-callback. Only a +/// `LookupMap::remove` + a refund Promise on the timeout branch, so it can be +/// small. TODO(#3265): benchmark. +const DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS: u64 = 10; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -64,6 +76,12 @@ pub(crate) struct Config { pub(crate) remove_non_participant_update_votes_tera_gas: u64, /// Prepaid gas for a `clean_foreign_chain_data` call. pub(crate) clean_foreign_chain_data_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub(crate) verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub(crate) resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub(crate) on_attestation_verified_tera_gas: u64, } impl Default for Config { @@ -88,6 +106,9 @@ impl Default for Config { remove_non_participant_update_votes_tera_gas: DEFAULT_REMOVE_NON_PARTICIPANT_UPDATE_VOTES_TERA_GAS, clean_foreign_chain_data_tera_gas: DEFAULT_CLEAN_FOREIGN_CHAIN_DATA_TERA_GAS, + verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS, + resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS, + on_attestation_verified_tera_gas: DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 433bcf7237..c10c22e6b3 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -472,6 +472,15 @@ impl From for Config { if let Some(v) = config_ext.clean_foreign_chain_data_tera_gas { config.clean_foreign_chain_data_tera_gas = v; } + if let Some(v) = config_ext.verifier_tera_gas { + config.verifier_tera_gas = v; + } + if let Some(v) = config_ext.resolve_verification_tera_gas { + config.resolve_verification_tera_gas = v; + } + if let Some(v) = config_ext.on_attestation_verified_tera_gas { + config.on_attestation_verified_tera_gas = v; + } config } @@ -499,6 +508,9 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { remove_non_participant_update_votes_tera_gas: value .remove_non_participant_update_votes_tera_gas, clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } @@ -525,6 +537,9 @@ impl From for Config { remove_non_participant_update_votes_tera_gas: value .remove_non_participant_update_votes_tera_gas, clean_foreign_chain_data_tera_gas: value.clean_foreign_chain_data_tera_gas, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index bfdec9e443..21b3c03f8f 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -28,6 +28,14 @@ pub enum TeeError { "Due to previously failed TEE validation, the network is not accepting new requests at this point in time. Try again later." )] TeeValidationFailed, + #[error( + "A Dstack attestation verification is already in flight for this account; wait for it to finish before resubmitting." + )] + VerificationAlreadyPending, + #[error( + "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." + )] + VerifierNotConfigured, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 87e99633fb..ea940d11b0 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -82,10 +82,12 @@ use primitives::{ thresholds::{Threshold, ThresholdParameters}, }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; -use tee::pending_attestation::PendingAttestation; +use tee::pending_attestation::{FinalOutcome, PendingAttestation}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; use tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}; +use tee_verifier_interface::{VerificationResult, VerifiedReport}; +use mpc_attestation::attestation::{Attestation, DstackAttestation}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, @@ -157,6 +159,27 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } +/// Refunds the deposit stashed for a `Dstack` submission whose verification did +/// not result in storage (verifier rejection, post-DCAP failure, or timeout). +fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { + if deposit > NearToken::from_yoctonear(0) { + log!("refund attestation deposit {deposit} to {account_id}"); + Promise::new(account_id.clone()).transfer(deposit).detach(); + } +} + +/// Maps an [`AttestationSubmissionError`] to the contract's error type, keeping +/// the message shape `submit_participant_info` used before the async split. +fn map_attestation_submission_error(err: AttestationSubmissionError) -> Error { + let reason = match &err { + AttestationSubmissionError::InvalidAttestation(_) => { + format!("TeeQuoteStatus is invalid: {err}") + } + AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), + }; + InvalidParameters::InvalidTeeRemoteAttestation { reason }.into() +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -776,12 +799,21 @@ impl MpcContract { /// (Prospective) Participants can submit their tee participant information through this /// endpoint. #[payable] + /// Submit a participant's TEE attestation. + /// + /// `Mock` attestations are verified synchronously (no DCAP) and stored + /// immediately, returning `Value(())`. `Dstack` attestations are verified + /// asynchronously: the method registers a yielded promise, fires a + /// cross-contract `verify_quote` call to the trusted `tee_verifier_account_id`, + /// and resolves from [`Self::resolve_verification`]. The returned `Promise` + /// settles when the verifier answers or, failing that, after the runtime's + /// ~200-block yield timeout (handled by [`Self::on_attestation_verified`]). #[handle_result] pub fn submit_participant_info( &mut self, proposed_participant_attestation: dtos::Attestation, tls_public_key: dtos::Ed25519PublicKey, - ) -> Result<(), Error> { + ) -> Result, Error> { let proposed_participant_attestation = proposed_participant_attestation.try_into_contract_type()?; @@ -795,10 +827,6 @@ impl MpcContract { account_key ); - // Save the initial storage usage to know how much to charge the proposer for the storage - // used - let initial_storage = env::storage_usage(); - let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); @@ -812,62 +840,268 @@ impl MpcContract { } })?; - // Add the participant information to the contract state - let attestation_insertion_result = self - .tee_state - .add_participant( - NodeId { - account_id: account_id.clone(), - tls_public_key, - account_public_key, - }, - proposed_participant_attestation, - tee_upgrade_deadline_duration, + let node_id = NodeId { + account_id: account_id.clone(), + tls_public_key, + account_public_key, + }; + let caller_is_not_participant = self.voter_account().is_err(); + + match proposed_participant_attestation { + Attestation::Mock(mock) => { + // Synchronous path: no DCAP, store immediately. + let initial_storage = env::storage_usage(); + let insertion = self + .tee_state + .add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) + .map_err(map_attestation_submission_error)?; + self.charge_attestation_storage( + &account_id, + initial_storage, + insertion, + caller_is_not_participant, + env::attached_deposit(), + )?; + Ok(PromiseOrValue::Value(())) + } + Attestation::Dstack(dstack) => { + Ok(self.submit_dstack_attestation(node_id, dstack, caller_is_not_participant)?) + } + } + } + + /// Registers the yield + cross-contract `verify_quote` call for a `Dstack` + /// submission. Returns the yielded `Promise` (via `enqueue_yield_request`'s + /// `promise_return`), wrapped as `PromiseOrValue::Value(())` because the + /// runtime has already taken the return value. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + caller_is_not_participant: bool, + ) -> Result, Error> { + let account_id = node_id.account_id.clone(); + + // One in-flight verification per account: a duplicate submit before the + // previous one finishes (verifier response or yield timeout) is rejected. + if self.pending_attestations.contains_key(&account_id) { + return Err(TeeError::VerificationAlreadyPending.into()); + } + + // Refuse to submit until a real verifier has been voted in: calling + // `verify_quote` on the placeholder would just time out. + if self.tee_verifier_account_id == initial_tee_verifier_account_id(None) { + return Err(TeeError::VerifierNotConfigured.into()); + } + + let (quote, collateral) = (dstack.quote.clone(), dstack.collateral.clone()); + let attached_deposit = env::attached_deposit(); + let tls_public_key = node_id.tls_public_key.clone(); + + self.enqueue_yield_request( + method_names::ON_ATTESTATION_VERIFIED, + borsh::to_vec(&account_id).expect("borsh serialization of account_id must succeed"), + Gas::from_tgas(self.config.on_attestation_verified_tera_gas), + |this, data_id| { + this.pending_attestations.insert( + account_id.clone(), + PendingAttestation { + dstack, + tls_public_key, + attached_deposit, + caller_is_not_participant, + data_id, + }, + ); + }, + ); + + // Cross-contract call to the verifier; its `.then` bridges the response + // into a `promise_yield_resume` on the yield registered above. + Promise::new(self.tee_verifier_account_id.clone()) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(quote, collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), ) - .map_err(|err| { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason } - })?; + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .resolve_verification(node_id), + ) + .detach(); - let caller_is_not_participant = self.voter_account().is_err(); - let is_new_attestation = matches!( - attestation_insertion_result, - ParticipantInsertion::NewlyInsertedParticipant - ); - - let attestation_storage_must_be_paid_by_caller = - is_new_attestation || caller_is_not_participant; - - if attestation_storage_must_be_paid_by_caller { - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing - // rather than underflow. Intentional asymmetry: we do not refund freed bytes - // either — the caller already paid for the larger entry, and we'd rather - // accept that asymmetry than open a refund path for payload-shrinking games. - let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - let attached = env::attached_deposit(); - - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), + // The yield handle is already the return value (`enqueue_yield_request` + // called `promise_return`); nothing further to return. + Ok(PromiseOrValue::Value(())) + } + + /// `.then` bridge between the verifier's `verify_quote` response and the + /// yield that [`Self::submit_dstack_attestation`] registered. Owns every + /// outcome where the verifier *answered*: + /// - `Verified` → run post-DCAP checks against fresh policy; on pass store + + /// resume `Ok`, on fail refund + resume `Err`. + /// - `Rejected` → refund + resume `Err` immediately (a definitive verdict). + /// + /// `Err(PromiseError)` (verifier unreachable / crashed) is deliberately not + /// resolved here: it logs and returns, leaving cleanup to the yield timeout + /// in [`Self::on_attestation_verified`]. `promise_yield_resume` is the last + /// host call, so a panic anywhere above rolls the whole receipt back and the + /// timeout still fires. + #[private] + pub fn resolve_verification( + &mut self, + node_id: NodeId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) { + let account_id = node_id.account_id.clone(); + let final_outcome = match result { + Err(promise_err) => { + // No verdict; let the yield timeout clean up. Do NOT resume, or + // we'd race the timeout for ownership of the cleanup path. + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; + } + Ok(VerificationResult::Rejected(reason)) => { + log!("verifier rejected quote for {account_id}: {reason}"); + FinalOutcome::Err(format!("verifier rejected quote: {reason}")) + } + Ok(VerificationResult::Verified(report)) => { + self.finish_verified_attestation(node_id, &report) + } + }; + + let pending = self + .pending_attestations + .remove(&account_id) + .expect("PendingAttestation must exist while resolve_verification holds the yield"); + if matches!(final_outcome, FinalOutcome::Err(_)) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + } + // MUST be the last host call: anything after could panic and roll back + // the state mutations above. + env::promise_yield_resume( + &pending.data_id, + borsh::to_vec(&final_outcome) + .expect("borsh serialization of FinalOutcome must succeed"), + ); + } + + /// Runs the post-DCAP checks for a verified report and, on success, stores + /// the attestation and charges storage. Returns the `FinalOutcome` to resume + /// the yield with. Does not remove the pending entry or resume — the caller + /// (`resolve_verification`) owns those. + fn finish_verified_attestation( + &mut self, + node_id: NodeId, + report: &VerifiedReport, + ) -> FinalOutcome { + let account_id = node_id.account_id.clone(); + let pending = self + .pending_attestations + .get(&account_id) + .expect("PendingAttestation must exist while resolve_verification holds the yield"); + let dstack = pending.dstack.clone(); + let caller_is_not_participant = pending.caller_is_not_participant; + let attached_deposit = pending.attached_deposit; + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + + let initial_storage = env::storage_usage(); + let insertion = match self.tee_state.finish_dstack_verify( + node_id, + dstack, + report, + tee_upgrade_deadline_duration, + ) { + Ok(insertion) => insertion, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return FinalOutcome::Err(format!("post-DCAP check failed: {err}")); + } + }; + + match self.charge_attestation_storage( + &account_id, + initial_storage, + insertion, + caller_is_not_participant, + attached_deposit, + ) { + Ok(()) => FinalOutcome::Ok, + Err(err) => FinalOutcome::Err(format!("{err:?}")), + } + } + + /// Yield-callback fired by the runtime once `resolve_verification` resumes + /// the yield, or after the ~200-block timeout if no resume landed. The + /// answered cases were already finalized by `resolve_verification`, so this + /// only routes the outcome back to the caller; the `Err(PromiseError)` + /// branch (verifier unreachable / silent timeout, where the pending entry is + /// still present) does the deferred cleanup. + #[private] + #[handle_result] + pub fn on_attestation_verified( + &mut self, + #[serializer(borsh)] account_id: AccountId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) -> Result<(), String> { + match result { + Ok(FinalOutcome::Ok) => Ok(()), + Ok(FinalOutcome::Err(reason)) => Err(reason), + Err(_promise_err) => { + if let Some(pending) = self.pending_attestations.remove(&account_id) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + log!("yield timeout for {account_id}: refunded and cleaned up"); } - .into()); + Err("verifier did not respond within the yield-resume window".to_string()) } + } + } - // Refund the difference if the proposer attached more than required - if let Some(diff) = attached.checked_sub(cost) - && diff > NearToken::from_yoctonear(0) - { - Promise::new(account_id).transfer(diff).detach(); + /// Charges the submitter for the storage their attestation occupies, + /// preserving the synchronous contract's rule: charge only when the entry is + /// newly inserted or the caller is not a participant; charge the actual + /// storage delta; refund any excess deposit; never refund freed bytes. + fn charge_attestation_storage( + &self, + account_id: &AccountId, + initial_storage: u64, + insertion: ParticipantInsertion, + caller_is_not_participant: bool, + attached: NearToken, + ) -> Result<(), Error> { + let is_new_attestation = + matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + if !(is_new_attestation || caller_is_not_participant) { + return Ok(()); + } + + // `saturating_sub`: if a re-submission shrinks the entry, charge nothing + // rather than underflow. Intentional asymmetry: we do not refund freed + // bytes either — the caller already paid for the larger entry. + let storage_used = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); + + if attached < cost { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: cost.as_yoctonear(), } + .into()); } + if let Some(diff) = attached.checked_sub(cost) + && diff > NearToken::from_yoctonear(0) + { + Promise::new(account_id.clone()).transfer(diff).detach(); + } Ok(()) } @@ -2577,7 +2811,6 @@ mod tests { use mpc_attestation::attestation::{ Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, }; - use mpc_primitives::hash::DockerImageHash; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; use near_mpc_contract_interface::types::CKDAppPublicKey; @@ -2595,10 +2828,6 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; - use test_utils::attestation::{ - VALID_ATTESTATION_TIMESTAMP, image_digest, launcher_image_hash, - mock_dto_dstack_attestation, near_account_key, p2p_tls_key, - }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; @@ -3767,7 +3996,9 @@ mod tests { .build(); testing_env!(participant_context); - contract.submit_participant_info(Attestation::Mock(attestation), dto_public_key) + contract + .submit_participant_info(Attestation::Mock(attestation), dto_public_key) + .map(|_| ()) } fn submit_valid_attestations( @@ -4000,7 +4231,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, participant_info.tls_public_key.clone()) .expect("Expected panic if predecessor != signer"); } @@ -4027,7 +4258,7 @@ mod tests { .build(); testing_env!(ctx); - contract + let _ = contract .submit_participant_info(valid_attestation, dto_public_key) .expect("Outsider attestation submission should succeed"); @@ -4089,7 +4320,7 @@ mod tests { .build() ); - contract + let _ = contract .submit_participant_info(Attestation::Mock(MockAttestation::Valid), dto_public_key) .unwrap(); @@ -5349,246 +5580,13 @@ mod tests { assert_eq!(*resharing_state, expected_resharing_state); } - /// Sets up a complete TEE test environment with contract, accounts, mock dstack attestation, TLS key and the node's near public key. - /// This is a helper function that provides all the common components needed for TEE-related tests. - fn setup_tee_test() -> ( - MpcContract, - Vec, - Attestation, - dtos::Ed25519PublicKey, - DockerImageHash, - near_sdk::PublicKey, - ) { - let (_context, contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); - - let participant_account_ids: Vec<_> = contract - .protocol_state - .threshold_parameters() - .unwrap() - .participants() - .participants() - .iter() - .map(|(account_id, _, _)| account_id.clone()) - .collect(); - - let attestation = mock_dto_dstack_attestation(); - let tls_key = p2p_tls_key().into(); - let mpc_hash = image_digest(); - let near_public_key = near_account_key(); - - ( - contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) - } - - /// Sets up a contract with an approved MPC hash by having the participants vote for it. - /// Also adds the legacy launcher image hash so that compose hashes are derived correctly. - /// This is a helper function commonly used in tests that require pre-approved hashes. - fn setup_approved_mpc_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - mpc_hash: &DockerImageHash, - block_timestamp_ns: u64, - ) { - // Add the legacy launcher image first, so that compose hashes are derived - // when the MPC hash is voted in. - setup_approved_launcher_hash(contract, participant_account_ids, block_timestamp_ns); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract.vote_code_hash(*mpc_hash).expect("vote succeeds"); - } - } - - /// Adds the launcher image hash from test attestation assets. - /// The hash is extracted from `test-utils/assets/launcher_image_compose.yaml`. - fn setup_approved_launcher_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - let launcher_hash = launcher_image_hash(); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_launcher_hash(launcher_hash) - .expect("launcher vote succeeds"); - } - } - - /// Adds the default OS measurements so that Dstack attestation verification passes. - fn setup_approved_measurements( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - for measurement in mpc_attestation::attestation::default_measurements() { - let contract_measurement = ContractExpectedMeasurements::from(*measurement); - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_os_measurement(contract_measurement.clone()) - .expect("measurement vote succeeds"); - } - } - } - - /// **Test method with matching measurements** - Tests that participant info submission succeeds with the test-only method. - /// Unlike the test above, this one has an approved MPC hash. It uses the test method with custom measurements that match - /// the attestation data. - #[test] - fn test_submit_participant_info_succeeds_with_valid_dstack_attestation() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - assert_matches::assert_matches!(result, Ok(())); - } - - /// Note - this test uses attestation data from a real MPC node. After Any change to the expected contract measurement, /test-utils/assets need to be updated. - /// see crates/test-utils/assets/README.md for details. - /// **No MPC hash approval** - Tests that participant info submission fails when no MPC hash has been approved yet. - /// This verifies the prerequisite step: the contract requires MPC hash approval before accepting any participant TEE information. - #[test] - fn test_submit_participant_info_fails_without_approved_mpc_hash() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - _mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")"), "Got error: {}", &error_string); - } - - /// **TLS key validation** - Tests that TEE attestation fails when TLS key doesn't match the one in report data. - /// Similar to the successful test method case above, but uses a deliberately corrupted TLS key to verify - /// that attestation validation properly checks the TLS key embedded in the attestation report. - #[test] - fn test_tee_attestation_fails_with_invalid_tls_key() { - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - // Create invalid TLS key by flipping the last bit - let mut invalid_tls_key_bytes = *tls_key.as_bytes(); - let last_byte_idx = invalid_tls_key_bytes.len() - 1; - invalid_tls_key_bytes[last_byte_idx] ^= 0x01; - let invalid_tls_key = Ed25519PublicKey::from(invalid_tls_key_bytes); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - - let result = contract.submit_participant_info(attestation, invalid_tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: WrongHash { name: \"report_data\""), "Got error: {}", &error_string); - } + // The end-to-end `Dstack` `submit_participant_info` paths (valid store, + // missing-MPC-hash rejection, bad-TLS-key rejection) are now asynchronous — + // they run the post-DCAP checks in `resolve_verification` after the verifier + // contract answers, which the in-process `testing_env!` harness cannot drive + // (no cross-contract runtime). They are covered by the sandbox tests in + // `tests/sandbox/tee.rs` against the stub verifier. The post-DCAP logic + // itself stays unit-tested in `mpc-attestation` (`verify_with_report`). fn make_launcher_hash(byte: u8) -> LauncherImageHash { LauncherImageHash::from([byte; 32]) diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 0c78d45daa..2a338653e0 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -29,6 +29,12 @@ pub struct PendingAttestation { /// from the callback receipt, so it is stashed here: consumed for storage /// staking on success, refunded on failure. pub attached_deposit: NearToken, + /// Whether the submitter was a non-participant at submit time. Together with + /// "is this a new attestation", this decides whether the caller pays for + /// storage (preserving the synchronous contract's charging rule). Captured + /// at submit time because participant status is re-derived from the caller, + /// which the callback receipt no longer is. + pub caller_is_not_participant: bool, /// Yield handle from `env::promise_yield_create`. The resolution callback /// reads it back to `promise_yield_resume` with the final outcome. pub data_id: CryptoHash, diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index bc96486748..001b6722b2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -11,13 +11,17 @@ use crate::{ }; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::{ - attestation::{self, AcceptedAttestation, Attestation, VerifiedAttestation}, + attestation::{ + self, AcceptedAttestation, Attestation, DstackAttestation, MockAttestation, + VerifiedAttestation, + }, report_data::{ReportData, ReportDataV1}, }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::Ed25519PublicKey; use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; +use tee_verifier_interface::VerifiedReport; pub use near_mpc_contract_interface::types::NodeId; @@ -142,29 +146,47 @@ impl TeeState { current_time_milliseconds / 1_000 } - /// Adds a participant attestation for the given node iff the attestation succeeds verification. + /// Test-only dispatch kept for the many existing tests that call + /// `add_participant` with a `Mock` fixture. Production code uses + /// [`Self::add_mock_participant`] (sync) or [`Self::finish_dstack_verify`] + /// (post-verifier) directly. Panics on `Dstack`, which has no synchronous + /// path; no test exercises that. + #[cfg(test)] pub(crate) fn add_participant( &mut self, node_id: NodeId, attestation: Attestation, tee_upgrade_deadline_duration: Duration, ) -> Result { - let expected_report_data: ReportData = ReportDataV1::new( - *node_id.tls_public_key.as_bytes(), - *node_id.account_public_key.as_bytes(), - ) - .into(); + match attestation { + Attestation::Mock(mock) => { + self.add_mock_participant(node_id, mock, tee_upgrade_deadline_duration) + } + Attestation::Dstack(_) => { + panic!("add_participant test helper does not support Dstack attestations") + } + } + } + /// Verifies and stores a `Mock` attestation synchronously. + /// + /// Mock attestations have no real quote, so there is no DCAP step and no + /// verifier round-trip — verification is local and immediate, unlike the + /// `Dstack` path which goes through [`Self::finish_dstack_verify`] after the + /// verifier contract responds. + pub(crate) fn add_mock_participant( + &mut self, + node_id: NodeId, + mock: MockAttestation, + tee_upgrade_deadline_duration: Duration, + ) -> Result { let accepted_measurements = self.get_accepted_measurements(); - // TODO(L4): this synchronous DCAP-in-contract path is replaced by the - // cross-contract call to `tee-verifier` + `verify_with_report`, which - // removes `dcap-qvl` from the contract WASM. Until then the contract - // keeps verifying locally so L1 stays behavior-neutral. + // Pure, always-compiled mock verification: no DCAP, so the contract does + // not link `dcap-qvl`. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify_locally( - expected_report_data.into(), + } = Attestation::Mock(mock).verify_mock_only( Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), @@ -172,7 +194,54 @@ impl TeeState { )?; log_informational_advisory_ids(&advisory_ids); + self.store_verified_attestation(node_id, verified_attestation) + } + /// Runs the post-DCAP checks for a `Dstack` attestation against the + /// `VerifiedReport` the verifier contract returned, then stores the result. + /// Called from `resolve_verification` once the cross-contract `verify_quote` + /// succeeds; the DCAP cryptographic verification has already happened in the + /// verifier contract. + pub(crate) fn finish_dstack_verify( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + report: &VerifiedReport, + tee_upgrade_deadline_duration: Duration, + ) -> Result { + let expected_report_data = Self::expected_report_data(&node_id); + let accepted_measurements = self.get_accepted_measurements(); + let AcceptedAttestation { + attestation: verified_attestation, + advisory_ids, + } = Attestation::Dstack(dstack).verify_with_report( + report, + expected_report_data, + Self::current_time_seconds(), + &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), + &self.get_allowed_launcher_compose_hashes(), + &accepted_measurements, + )?; + + log_informational_advisory_ids(&advisory_ids); + self.store_verified_attestation(node_id, verified_attestation) + } + + fn expected_report_data(node_id: &NodeId) -> ::attestation::report_data::ReportData { + let report_data: ReportData = ReportDataV1::new( + *node_id.tls_public_key.as_bytes(), + *node_id.account_public_key.as_bytes(), + ) + .into(); + report_data.into() + } + + /// Stores an already-verified attestation, enforcing TLS-key ownership. + fn store_verified_attestation( + &mut self, + node_id: NodeId, + verified_attestation: VerifiedAttestation, + ) -> Result { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index cb02d3e81c..91b8c35209 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -218,8 +218,11 @@ impl TestSetup { ) -> Result<(), mpc_contract::errors::Error> { let context = create_context_for_participant(&node_id.account_id); testing_env!(context); + // These tests submit `Mock` attestations, which resolve synchronously to + // `PromiseOrValue::Value(())`; discard the value and surface only errors. self.contract .submit_participant_info(attestation, node_id.tls_public_key.clone()) + .map(|_| ()) } /// Switches testing context to a given participant at a specific timestamp @@ -329,10 +332,13 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { .attached_deposit(NearToken::from_near(1)) .build() ); - let attack_result = setup.contract.submit_participant_info( - Attestation::Mock(MockAttestation::Valid), - attacker_node.tls_public_key.clone(), - ); + let attack_result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + attacker_node.tls_public_key.clone(), + ) + .map(|_| ()); // Then: the contract rejects the call with the TLS-ownership error and the victim's // entry is unchanged. diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 2f5be02cfb..e3f72eea72 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -102,6 +102,9 @@ async fn contract_configuration_can_be_set_on_initialization() { cleanup_orphaned_node_migrations_tera_gas: Some(11), remove_non_participant_update_votes_tera_gas: Some(12), clean_foreign_chain_data_tera_gas: Some(13), + verifier_tera_gas: Some(14), + resolve_verification_tera_gas: Some(15), + on_attestation_verified_tera_gas: Some(16), // `tee_verifier_account_id` is not part of `Config`, so the `config()` // view round-trips it as `None`; keep it `None` here so the assertion holds. tee_verifier_account_id: None, diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index 1900cf325b..b368c50d04 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -118,6 +118,9 @@ async fn test_propose_update_config() { cleanup_orphaned_node_migrations_tera_gas: 11, remove_non_participant_update_votes_tera_gas: 12, clean_foreign_chain_data_tera_gas: 13, + verifier_tera_gas: 14, + resolve_verification_tera_gas: 15, + on_attestation_verified_tera_gas: 16, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index d4007557b3..be991e04c6 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -209,6 +209,33 @@ impl Attestation { } } + /// Verifies a `Mock` attestation. Pure and always compiled (no DCAP, no + /// `local-verify` feature), so the contract can verify mock submissions + /// synchronously without linking `dcap-qvl`. + /// + /// Returns an error for `Dstack` attestations: those require real DCAP + /// verification, which is not available on this path. + pub fn verify_mock_only( + &self, + current_timestamp_seconds: u64, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + ) -> Result { + match self { + Self::Mock(mock_attestation) => verify_mock( + mock_attestation, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + current_timestamp_seconds, + ), + Self::Dstack(_) => Err(VerificationError::Custom( + "verify_mock_only called on a Dstack attestation".to_string(), + )), + } + } + /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then /// the post-DCAP checks. Off-chain only (the `local-verify` feature pulls /// in `dcap-qvl`). diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index a551cf721e..4db97f3a89 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -55,12 +55,17 @@ pub const CLEAN_TEE_STATUS: &str = "clean_tee_status"; pub const CLEAN_INVALID_ATTESTATIONS: &str = "clean_invalid_attestations"; pub const CLEAN_FOREIGN_CHAIN_DATA: &str = "clean_foreign_chain_data"; +// TEE verifier contract (the method `mpc-contract` calls cross-contract) +pub const VERIFY_QUOTE: &str = "verify_quote"; + // Callbacks (used in promise_yield_create and indexed by the node) pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_signature_and_clean_state_on_success"; pub const RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_ck_and_clean_state_on_success"; pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; +pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; +pub const ON_ATTESTATION_VERIFIED: &str = "on_attestation_verified"; // View methods pub const STATE: &str = "state"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 150cb6306f..02da91215d 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -47,6 +47,12 @@ pub struct InitConfig { pub remove_non_participant_update_votes_tera_gas: Option, /// Prepaid gas for a `clean_foreign_chain_data` call. pub clean_foreign_chain_data_tera_gas: Option, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: Option, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: Option, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: Option, /// The account whose `verify_quote` method the contract trusts for TEE /// attestation verification. Fresh deploys may set it here; otherwise the /// contract starts with a placeholder and participants vote one in via @@ -100,6 +106,12 @@ pub struct Config { pub remove_non_participant_update_votes_tera_gas: u64, /// Prepaid gas for a `clean_foreign_chain_data` call. pub clean_foreign_chain_data_tera_gas: u64, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: u64, } #[cfg(test)] @@ -123,6 +135,9 @@ mod tests { cleanup_orphaned_node_migrations_tera_gas: Some(3), remove_non_participant_update_votes_tera_gas: Some(5), clean_foreign_chain_data_tera_gas: Some(5), + verifier_tera_gas: Some(100), + resolve_verification_tera_gas: Some(60), + on_attestation_verified_tera_gas: Some(10), tee_verifier_account_id: Some("verifier.near".parse().unwrap()), }; let json = serde_json::to_string(&original_config).unwrap(); @@ -173,6 +188,9 @@ mod tests { cleanup_orphaned_node_migrations_tera_gas: None, remove_non_participant_update_votes_tera_gas: None, clean_foreign_chain_data_tera_gas: None, + verifier_tera_gas: None, + resolve_verification_tera_gas: None, + on_attestation_verified_tera_gas: None, tee_verifier_account_id: None, }; diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 37aca15560..ed6ea8354c 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -14,5 +14,8 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { cleanup_orphaned_node_migrations_tera_gas: value + 10, remove_non_participant_update_votes_tera_gas: value + 11, clean_foreign_chain_data_tera_gas: value + 12, + verifier_tera_gas: value + 13, + resolve_verification_tera_gas: value + 14, + on_attestation_verified_tera_gas: value + 15, } } From 326f38e956f2e73ee698061518a062cffb9e2b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 19:56:07 +0200 Subject: [PATCH 05/21] test(contract): stub tee-verifier + sandbox tests for async attestation flow Adds the test-tee-verifier stub contract (returns a test-chosen verify_quote answer instead of running dcap-qvl, speaking the same Borsh DTOs as the real verifier) and sandbox tests that drive the async submit_participant_info paths: verifier-not-configured rejection, verifier Rejected (refund, nothing stored), and verifier crash / no-verdict (yield-timeout cleanup). The Verified + post-DCAP-pass path needs a fixture-matching stub report and is a tracked follow-up; the post-DCAP logic stays unit-tested in mpc-attestation. Sandbox tests require the contract WASM toolchain and run in CI. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/contract/tests/sandbox/mod.rs | 1 + crates/contract/tests/sandbox/tee_verifier.rs | 201 ++++++++++++++++++ .../tests/sandbox/utils/contract_build.rs | 9 + .../tests/sandbox/utils/mpc_contract.rs | 18 ++ crates/test-tee-verifier/Cargo.toml | 26 +++ crates/test-tee-verifier/src/lib.rs | 77 +++++++ 8 files changed, 343 insertions(+) create mode 100644 crates/contract/tests/sandbox/tee_verifier.rs create mode 100644 crates/test-tee-verifier/Cargo.toml create mode 100644 crates/test-tee-verifier/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 80da90d532..1a442de9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11485,6 +11485,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "test-tee-verifier" +version = "3.11.0" +dependencies = [ + "borsh", + "getrandom 0.2.17", + "near-sdk", + "tee-verifier-interface", +] + [[package]] name = "test-utils" version = "3.11.0" diff --git a/Cargo.toml b/Cargo.toml index 70b0f159db..90102523dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-port-allocator", + "crates/test-tee-verifier", "crates/test-utils", "crates/threshold-signatures", "crates/tls", diff --git a/crates/contract/tests/sandbox/mod.rs b/crates/contract/tests/sandbox/mod.rs index c4e4cbaf3d..e0f25b397a 100644 --- a/crates/contract/tests/sandbox/mod.rs +++ b/crates/contract/tests/sandbox/mod.rs @@ -6,6 +6,7 @@ pub mod participants_gas; pub mod sign; pub mod tee; pub mod tee_cleanup_after_resharing; +pub mod tee_verifier; pub mod update_votes_cleanup_after_resharing; pub mod upgrade_from_current_contract; pub mod upgrade_to_current_contract; diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..66ea4800d1 --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,201 @@ +//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP +//! verification to a separate `tee-verifier` contract. +//! +//! These deploy the `test-tee-verifier` stub (which returns a test-chosen +//! `verify_quote` answer instead of running real `dcap-qvl`) and point +//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each +//! resolution branch of the yield-resume flow: +//! +//! - verifier not configured → submission rejected, nothing stored. +//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +//! +//! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs +//! a stub report matching the fixture's post-DCAP expectations; it is a planned +//! follow-up (TODO(#3265)) once the off-chain report helper is wired into the +//! sandbox harness. The post-DCAP logic itself is unit-tested in `mpc-attestation`. +//! +//! They require the cross-contract runtime, so they live in sandbox rather than +//! the in-process tests. The WASM build needs the contract toolchain; these run +//! in CI. +#![allow(non_snake_case)] + +use crate::sandbox::{ + common::SandboxTestSetup, + utils::{ + consts::ALL_PROTOCOLS, + contract_build::stub_tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, submit_participant_info, vote_tee_verifier_change, + }, + }, +}; +use anyhow::Result; +use borsh::BorshSerialize; +use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; + +/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than +/// depending on the stub crate) so the test only needs its Borsh encoding to +/// initialize the deployed stub. +#[derive(BorshSerialize)] +enum StubResponse { + #[allow(dead_code)] + Verified(tee_verifier_interface::VerifiedReport), + Rejected(String), + Panic, +} + +/// Deploys the stub verifier with the given response, initializes it, and votes +/// it in as `mpc-contract`'s trusted verifier (all participants vote so the +/// change crosses threshold). +async fn deploy_and_trust_stub( + worker: &Worker, + contract: &Contract, + participants: &[Account], + response: StubResponse, +) -> Result { + let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; + stub.call("new") + .args_borsh(response) + .transact() + .await? + .into_result()?; + + // The contract only consumes `candidate_account_id`; the hash is a voter + // commitment, so any agreed value works for the test. + let expected_code_hash = [7u8; 32]; + for account in participants { + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + } + Ok(stub) +} + +fn dstack_attestation() -> Attestation { + mock_dto_dstack_attestation() +} + +fn tls_key() -> dtos::Ed25519PublicKey { + p2p_tls_key().into() +} + +#[tokio::test] +async fn submit_participant_info__rejects_dstack_when_verifier_not_configured() -> Result<()> { + // Given: a running contract with no verifier voted in (placeholder). + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // When: a participant submits a Dstack attestation. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &dstack_attestation(), + &tls_key(), + ) + .await?; + + // Then: it is rejected (no verifier configured) and nothing is stored. + assert!( + result.is_failure(), + "Dstack submit must fail when no verifier is configured: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "no attestation should be stored"); + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejection() -> Result<()> { + // Given: a contract whose trusted verifier always rejects. + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("test rejection".to_string()), + ) + .await?; + + // When: a participant submits a Dstack attestation. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let result = + submit_participant_info(submitter, &contract, &dstack_attestation(), &tls_key()).await?; + + // Then: the call resolves as a failure (the rejection reason), nothing is + // stored, and the deposit is refunded (final balance is close to the + // pre-call balance, modulo gas). + assert!( + result.is_failure(), + "a verifier rejection must surface as a failed submission: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "a rejected quote must not be stored"); + let balance_after = submitter.view_account().await?.balance; + // The 1 NEAR storage deposit must have been refunded; only gas was spent. + assert!( + balance_before.saturating_sub(balance_after) < NearToken::from_near(1), + "deposit should be refunded on rejection" + ); + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { + // Given: a contract whose trusted verifier panics (no verdict). The yield + // times out after ~200 blocks; the test advances the chain to trigger it. + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Panic, + ) + .await?; + + // When: a participant submits and the verifier crashes. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &dstack_attestation(), + &tls_key(), + ) + .await?; + + // Then: the submission ultimately fails (the yield resolves to an error on + // timeout) and nothing is stored. `transact()` awaits the yielded promise's + // resolution, so the timeout has already fired by the time we observe this. + assert!( + result.is_failure(), + "a crashing verifier must surface as a failed submission: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when the verifier crashes" + ); + Ok(()) +} diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..914b944fd8 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -4,6 +4,7 @@ use test_utils::contract_build::ContractBuilder; const MPC_CONTRACT_MANIFEST: &str = "crates/contract/Cargo.toml"; const MIGRATION_CONTRACT_MANIFEST: &str = "crates/test-migration-contract/Cargo.toml"; const PARALLEL_CONTRACT_MANIFEST: &str = "crates/test-parallel-contract/Cargo.toml"; +const STUB_TEE_VERIFIER_MANIFEST: &str = "crates/test-tee-verifier/Cargo.toml"; const MPC_CONTRACT_OUT_DIR: &str = "target/near/contract-noabi"; const MPC_CONTRACT_BENCH_OUT_DIR: &str = "target/near/contract-noabi-bench"; const MPC_CONTRACT_SANDBOX_OUT_DIR: &str = "target/near/contract-noabi-sandbox"; @@ -13,6 +14,7 @@ static CONTRACT_WITH_BENCH_METHODS: OnceLock> = OnceLock::new(); static CONTRACT_WITH_SANDBOX_TEST_METHODS: OnceLock> = OnceLock::new(); static MIGRATION_CONTRACT: OnceLock> = OnceLock::new(); static PARALLEL_CONTRACT: OnceLock> = OnceLock::new(); +static STUB_TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -54,3 +56,10 @@ pub fn migration_contract() -> &'static [u8] { pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } + +/// The test-only stub `tee-verifier` WASM. Deployed in place of the real +/// verifier so attestation tests can choose `verify_quote`'s answer. +pub fn stub_tee_verifier_contract() -> &'static [u8] { + STUB_TEE_VERIFIER_CONTRACT + .get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build()) +} diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index ce9690131e..5dc04225ef 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -126,3 +126,21 @@ pub async fn vote_add_launcher_hash( all_receipts_successful(result)?; Ok(()) } + +pub async fn vote_tee_verifier_change( + account: &Account, + contract: &Contract, + candidate_account_id: &near_workspaces::AccountId, + expected_code_hash: [u8; 32], +) -> anyhow::Result<()> { + let result = account + .call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE) + .args_json(serde_json::json!({ + "candidate_account_id": candidate_account_id, + "expected_code_hash": expected_code_hash, + })) + .transact() + .await?; + all_receipts_successful(result)?; + Ok(()) +} diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml new file mode 100644 index 0000000000..3327f453ca --- /dev/null +++ b/crates/test-tee-verifier/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "test-tee-verifier" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a +# response the test chose at init, instead of running real `dcap-qvl`. Lets the +# `mpc-contract` sandbox tests drive every branch of the async attestation flow +# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. +# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so +# `mpc-contract` cannot tell them apart. + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +borsh = { workspace = true } +near-sdk = { workspace = true } +tee-verifier-interface = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true, features = ["custom"] } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs new file mode 100644 index 0000000000..c207902011 --- /dev/null +++ b/crates/test-tee-verifier/src/lib.rs @@ -0,0 +1,77 @@ +//! Test-only stub of the `tee-verifier` contract. +//! +//! `verify_quote` ignores its inputs and returns a response fixed at init time, +//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox +//! tests drive every branch of the async attestation flow deterministically: +//! a `Verified` report (which the test supplies so it matches the fixture's +//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! verifier-unreachable path). +//! +//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same +//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot +//! tell the two apart. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::{env, near}; +use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; + +// Match the real verifier's getrandom handling on wasm so the crate links. +#[cfg(target_arch = "wasm32")] +fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) +} +#[cfg(target_arch = "wasm32")] +getrandom::register_custom_getrandom!(randomness_unsupported); + +/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub enum StubResponse { + /// Return `VerificationResult::Verified` with this exact report. Tests that + /// want the post-DCAP checks to pass supply the report obtained from the + /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + Verified(tee_verifier_interface::VerifiedReport), + /// Return `VerificationResult::Rejected` with this reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict + /// path that `mpc-contract` resolves via the yield timeout). + Panic, +} + +#[derive(Debug)] +#[near(contract_state)] +pub struct TestTeeVerifier { + response: StubResponse, +} + +impl Default for TestTeeVerifier { + fn default() -> Self { + // A contract must be initialized via `new`; default would never be used + // by a test, but `#[near(contract_state)]` requires the bound. + env::panic_str("TestTeeVerifier must be initialized with `new`") + } +} + +#[near] +impl TestTeeVerifier { + #[init] + pub fn new(#[serializer(borsh)] response: StubResponse) -> Self { + Self { response } + } + + /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` + /// and returns the canned response. Panics on `StubResponse::Panic`. + #[result_serializer(borsh)] + pub fn verify_quote( + &self, + #[serializer(borsh)] _quote: QuoteBytes, + #[serializer(borsh)] _collateral: Collateral, + ) -> VerificationResult { + match &self.response { + StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), + StubResponse::Rejected(reason) => { + VerificationResult::Rejected(VerifierError::DcapVerification(reason.clone())) + } + StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), + } + } +} From 28de2f6becc736d98556f43cdb4708e777ec3128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 20:01:29 +0200 Subject: [PATCH 06/21] test: fix attestation-cli expiry assertion + silence stub enum lint - attestation-cli full-verification test asserted the 7-day expiry literal; switch it to DEFAULT_EXPIRATION_DURATION_SECONDS so it tracks the constant (now 1 day) like the mpc-attestation integration tests. - expect(large_enum_variant) on the StubResponse enums (stub crate + sandbox mirror): the Verified(VerifiedReport) variant is large by design. --- crates/attestation-cli/tests/test_verification.rs | 4 ++-- crates/contract/tests/sandbox/tee_verifier.rs | 1 + crates/test-tee-verifier/src/lib.rs | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/attestation-cli/tests/test_verification.rs b/crates/attestation-cli/tests/test_verification.rs index ac9eaf488b..512d5354ac 100644 --- a/crates/attestation-cli/tests/test_verification.rs +++ b/crates/attestation-cli/tests/test_verification.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use attestation_cli::cli::Cli; use attestation_cli::verify; -use mpc_attestation::attestation::Attestation; +use mpc_attestation::attestation::{Attestation, DEFAULT_EXPIRATION_DURATION_SECONDS}; use near_mpc_crypto_types::Ed25519PublicKey; use node_types::http_server::StaticWebData; use test_utils::attestation::{ @@ -56,7 +56,7 @@ fn full_verification_succeeds_with_valid_attestation() { assert_eq!(result.mpc_image_hash.as_hex(), TEST_MPC_IMAGE_DIGEST_HEX); assert_eq!( result.expiry_timestamp_seconds, - VALID_ATTESTATION_TIMESTAMP + 60 * 60 * 24 * 7 + VALID_ATTESTATION_TIMESTAMP + DEFAULT_EXPIRATION_DURATION_SECONDS ); } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 66ea4800d1..b948347a09 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -39,6 +39,7 @@ use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; /// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than /// depending on the stub crate) so the test only needs its Borsh encoding to /// initialize the deployed stub. +#[expect(clippy::large_enum_variant)] #[derive(BorshSerialize)] enum StubResponse { #[allow(dead_code)] diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index c207902011..ddb816bccd 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -24,6 +24,7 @@ fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { getrandom::register_custom_getrandom!(randomness_unsupported); /// What the stub's `verify_quote` should do, chosen by the test at deploy time. +#[expect(clippy::large_enum_variant)] #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] pub enum StubResponse { /// Return `VerificationResult::Verified` with this exact report. Tests that From 332cb465cd9c258117903f527abc8f17f2726e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 20:02:45 +0200 Subject: [PATCH 07/21] docs(attestation-verifier): add implemented-status banner with remaining follow-ups --- docs/design/attestation-verifier-contract.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..a77dd4a7e9 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -1,5 +1,15 @@ # Attestation Verifier Contract Breakout +> **Status: largely implemented.** The verifier contract (`tee-verifier`) and +> wire DTOs (`tee-verifier-interface`) shipped first; `mpc-contract` now offloads +> DCAP verification to the verifier via the async `submit_participant_info` flow +> described below, the verifier-change vote is in place, and `dcap-qvl` is no +> longer linked into `mpc-contract`. Two follow-ups remain: collapsing the +> JSON-facing `near-mpc-contract-interface::Collateral` into the interface type +> (TODO(#3494) — an API-wire migration deferred to its own change), and a sandbox +> test for the `Verified` + post-DCAP-pass branch (the other branches are +> covered). + This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract. It supersedes [#3160](https://github.com/near/mpc/pull/3160), which sketched a three-contract architecture (shared verifier + per-team policy contract + TEE-agnostic application contract) for Defuse, Proximity, and other teams. That direction was deferred: a shared policy contract presumes shared lifecycle conventions (the [launcher pattern][launcher-pattern] `mpc-contract` uses), and aligning the other teams on those conventions is a separate, longer [conversation][slack-launcher-discussion] that has not yet converged. From f4f29e5b29306b0b4400033a08681b3794203ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 20:41:19 +0200 Subject: [PATCH 08/21] fix(contract): use plain borsh for PendingAttestation/FinalOutcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under --all-features the contract enables `abi`, where #[near(serializers=[borsh])] also derives BorshSchema. PendingAttestation embeds DstackAttestation, which has no BorshSchema, so ABI generation failed to compile. These are internal-only state types never present in a public method signature, so they don't need a schema — switch them to plain #[derive(BorshSerialize, BorshDeserialize)]. --- crates/contract/src/tee/pending_attestation.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 2a338653e0..a65194304d 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -11,12 +11,17 @@ //! here so the state field and storage key land first. use mpc_attestation::attestation::DstackAttestation; +use borsh::{BorshDeserialize, BorshSerialize}; use near_mpc_contract_interface::types::Ed25519PublicKey; -use near_sdk::{CryptoHash, NearToken, near}; +use near_sdk::{CryptoHash, NearToken}; /// One in-flight verification per submitter account. -#[near(serializers=[borsh])] -#[derive(Debug)] +// +// Plain borsh (not `#[near(serializers=[borsh])]`): this is internal contract +// state that never appears in a public method signature, so it does not need a +// `BorshSchema` for ABI generation — and avoiding it keeps `DstackAttestation` +// out of the ABI schema requirement. +#[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct PendingAttestation { /// The submitted `Dstack` payload — RTMR3 event log, app-compose, and the /// quote/collateral — that the post-DCAP checks consume once the verifier @@ -42,8 +47,7 @@ pub struct PendingAttestation { /// Outcome the resolution callback resumes the yielded promise with. The /// yield-callback maps it back to a `Result` for the original caller. -#[near(serializers=[borsh])] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub enum FinalOutcome { Ok, Err(String), From 3fa3535a64450e3719db2166e54be41625113df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 20:55:11 +0200 Subject: [PATCH 09/21] fix(contract): satisfy --all-features ABI build for verifier stub + FinalOutcome - FinalOutcome reaches ABI schema generation as on_attestation_verified's #[callback_result] arg, so derive BorshSchema under `abi`. - Give test-tee-verifier an `abi` feature (mirroring the real verifier) so --all-features enables borsh-schema for the wire DTOs, and derive BorshSchema on StubResponse under it. - Use #[expect(dead_code)] over #[allow] on the sandbox stub mirror's unused Verified variant. --- crates/contract/src/tee/pending_attestation.rs | 9 +++++++++ crates/contract/tests/sandbox/tee_verifier.rs | 5 ++++- crates/test-tee-verifier/Cargo.toml | 5 +++++ crates/test-tee-verifier/src/lib.rs | 4 ++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index a65194304d..586a1c1b45 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -47,7 +47,16 @@ pub struct PendingAttestation { /// Outcome the resolution callback resumes the yielded promise with. The /// yield-callback maps it back to a `Result` for the original caller. +// +// `FinalOutcome` reaches ABI generation as the `#[callback_result]` argument +// type of `on_attestation_verified`, so it needs a `BorshSchema` under `abi` +// (unlike `PendingAttestation`, which is pure state). Both its variants are +// schema-able (`()` and `String`). #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] pub enum FinalOutcome { Ok, Err(String), diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index b948347a09..5681a1d9ca 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -42,7 +42,10 @@ use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; #[expect(clippy::large_enum_variant)] #[derive(BorshSerialize)] enum StubResponse { - #[allow(dead_code)] + // The Verified branch is exercised by the (deferred) post-DCAP-pass test; it + // is part of the stub's wire contract, so keep the variant even though no + // current test constructs it. + #[expect(dead_code)] Verified(tee_verifier_interface::VerifiedReport), Rejected(String), Panic, diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml index 3327f453ca..f9612d5569 100644 --- a/crates/test-tee-verifier/Cargo.toml +++ b/crates/test-tee-verifier/Cargo.toml @@ -14,6 +14,11 @@ edition = { workspace = true } [lib] crate-type = ["cdylib", "lib"] +[features] +# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring +# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + [dependencies] borsh = { workspace = true } near-sdk = { workspace = true } diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs index ddb816bccd..573bdb3dd7 100644 --- a/crates/test-tee-verifier/src/lib.rs +++ b/crates/test-tee-verifier/src/lib.rs @@ -26,6 +26,10 @@ getrandom::register_custom_getrandom!(randomness_unsupported); /// What the stub's `verify_quote` should do, chosen by the test at deploy time. #[expect(clippy::large_enum_variant)] #[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] pub enum StubResponse { /// Return `VerificationResult::Verified` with this exact report. Tests that /// want the post-DCAP checks to pass supply the report obtained from the From d1d1486c6574138ba30a1729230c3f92c96f99b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 21:01:20 +0200 Subject: [PATCH 10/21] test(contract): update borsh-schema snapshot for new verifier state Reflects the added MpcContract fields (tee_verifier_account_id, tee_verifier_votes, pending_attestations), the TeeVerifierVotes / Votes schema types, and the new Config gas knobs. Diff reviewed: only the intended additions. --- ...contract_borsh_schema_has_not_changed.snap | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap index 91cd80f2ad..116c2c7969 100644 --- a/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap +++ b/crates/contract/src/snapshots/mpc_contract__tests__mpc_contract_borsh_schema_has_not_changed.snap @@ -253,6 +253,18 @@ BorshSchemaContainer { "clean_foreign_chain_data_tera_gas", "u64", ), + ( + "verifier_tera_gas", + "u64", + ), + ( + "resolve_verification_tera_gas", + "u64", + ), + ( + "on_attestation_verified_tera_gas", + "u64", + ), ], ), }, @@ -702,6 +714,18 @@ BorshSchemaContainer { "foreign_chain_rpc_whitelist", "ForeignChainRpcWhitelist", ), + ( + "tee_verifier_account_id", + "AccountId", + ), + ( + "tee_verifier_votes", + "TeeVerifierVotes", + ), + ( + "pending_attestations", + "LookupMap", + ), ], ), }, @@ -1147,6 +1171,16 @@ BorshSchemaContainer { ], ), }, + "TeeVerifierVotes": Struct { + fields: NamedFields( + [ + ( + "pending", + "Votes", + ), + ], + ), + }, "Threshold": Struct { fields: UnnamedFields( [ @@ -1260,6 +1294,20 @@ BorshSchemaContainer { ], ), }, + "Votes": Struct { + fields: NamedFields( + [ + ( + "proposal_by_voter", + "IterableMap", + ), + ( + "votes_by_proposal", + "IterableMap", + ), + ], + ), + }, "[u8; 32]": Sequence { length_width: 0, length_range: 32..=32, From 9bcd696b4a50149875d19b85dcb9c15c3e33263c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 11 Jun 2026 21:18:07 +0200 Subject: [PATCH 11/21] fix(contract): shadow old Config layout in v3_11_2 migration state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier integration added three gas fields to Config, changing its borsh layout. The migration shadow struct embedded the *current* Config, so deployed (pre-change) state failed to deserialize and migrate() panicked — the upgrade was rolled back (caught by the contract_upgrade_compatibility e2e test). Shadow the old 13-field Config as OldConfig and map it to the current Config, filling the new gas fields from defaults. --- crates/contract/src/v3_11_2_state.rs | 56 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 9ea7c7fbe1..9c305d92d7 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -37,7 +37,9 @@ pub struct MpcContract { pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, node_foreign_chain_support: SupportedForeignChainsByNode, - config: Config, + // Shadowed: the verifier integration added three gas fields to `crate::Config`, + // so the deployed borsh layout differs from the current one. + config: OldConfig, tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, @@ -45,6 +47,56 @@ pub struct MpcContract { foreign_chain_rpc_whitelist: ForeignChainRpcWhitelist, } +/// The `Config` borsh layout deployed before the verifier integration added the +/// `verifier_tera_gas` / `resolve_verification_tera_gas` / +/// `on_attestation_verified_tera_gas` fields. Shadowed so deployed state still +/// deserializes; the conversion below fills the new fields from `Config`'s +/// defaults. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct OldConfig { + key_event_timeout_blocks: u64, + tee_upgrade_deadline_duration_seconds: u64, + contract_upgrade_deposit_tera_gas: u64, + sign_call_gas_attachment_requirement_tera_gas: u64, + ckd_call_gas_attachment_requirement_tera_gas: u64, + return_signature_and_clean_state_on_success_call_tera_gas: u64, + return_ck_and_clean_state_on_success_call_tera_gas: u64, + fail_on_timeout_tera_gas: u64, + clean_tee_status_tera_gas: u64, + clean_invalid_attestations_tera_gas: u64, + cleanup_orphaned_node_migrations_tera_gas: u64, + remove_non_participant_update_votes_tera_gas: u64, + clean_foreign_chain_data_tera_gas: u64, +} + +impl From for Config { + fn from(old: OldConfig) -> Self { + // Start from defaults so the three new gas fields get sensible values, + // then carry over every field that existed before. + Config { + key_event_timeout_blocks: old.key_event_timeout_blocks, + tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, + contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, + sign_call_gas_attachment_requirement_tera_gas: old + .sign_call_gas_attachment_requirement_tera_gas, + ckd_call_gas_attachment_requirement_tera_gas: old + .ckd_call_gas_attachment_requirement_tera_gas, + return_signature_and_clean_state_on_success_call_tera_gas: old + .return_signature_and_clean_state_on_success_call_tera_gas, + return_ck_and_clean_state_on_success_call_tera_gas: old + .return_ck_and_clean_state_on_success_call_tera_gas, + fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, + clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, + clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old.cleanup_orphaned_node_migrations_tera_gas, + remove_non_participant_update_votes_tera_gas: old + .remove_non_participant_update_votes_tera_gas, + clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, + ..Config::default() + } + } +} + impl From for crate::MpcContract { fn from(old: MpcContract) -> Self { if !matches!(old.protocol_state, ProtocolContractState::Running(_)) { @@ -58,7 +110,7 @@ impl From for crate::MpcContract { pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, proposed_updates: old.proposed_updates, node_foreign_chain_support: old.node_foreign_chain_support, - config: old.config, + config: old.config.into(), tee_state: old.tee_state, accept_requests: old.accept_requests, node_migrations: old.node_migrations, From a6e5ad3172ec26f9aadfd9f7819845dd838f9f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 10:20:49 +0200 Subject: [PATCH 12/21] fix: conform two TODO(#NNNN) prose references to the TODO-format check The check requires `TODO(#NNNN):` (with colon) or `TODO:`; two parenthetical prose mentions of issue numbers tripped it. Reworded to "see issue #NNNN". --- crates/contract/tests/sandbox/tee_verifier.rs | 2 +- docs/design/attestation-verifier-contract.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 5681a1d9ca..551115c87e 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -12,7 +12,7 @@ //! //! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs //! a stub report matching the fixture's post-DCAP expectations; it is a planned -//! follow-up (TODO(#3265)) once the off-chain report helper is wired into the +//! follow-up (see issue #3265) once the off-chain report helper is wired into the //! sandbox harness. The post-DCAP logic itself is unit-tested in `mpc-attestation`. //! //! They require the cross-contract runtime, so they live in sandbox rather than diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index a77dd4a7e9..bcf35b2352 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -6,7 +6,7 @@ > described below, the verifier-change vote is in place, and `dcap-qvl` is no > longer linked into `mpc-contract`. Two follow-ups remain: collapsing the > JSON-facing `near-mpc-contract-interface::Collateral` into the interface type -> (TODO(#3494) — an API-wire migration deferred to its own change), and a sandbox +> (issue #3494 — an API-wire migration deferred to its own change), and a sandbox > test for the `Verified` + post-DCAP-pass branch (the other branches are > covered). From 21033efabb68700492e8cf5e3b380142b73f4b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 10:33:02 +0200 Subject: [PATCH 13/21] fix: hoist use statements out of function bodies (no-use-in-fn lint) The repo forbids `use` inside fn bodies. Move the dcap-conversion trait import to a feature-gated module-level use in `attestation`, hoist the report_data test's imports to the test module (aliasing the quote fixture to avoid shadowing), and drop a redundant in-fn import in the mpc-attestation integration test. --- crates/attestation/src/attestation.rs | 5 +++-- crates/mpc-attestation/src/report_data.rs | 8 +++----- .../tests/test_attestation_verification.rs | 2 -- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 38a1648b39..22af99f4b2 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -19,6 +19,9 @@ 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"; @@ -194,8 +197,6 @@ impl DstackAttestation { /// ([`verify_with_report`](Self::verify_with_report)). #[cfg(feature = "local-verify")] pub fn dcap_report(&self, timestamp_seconds: u64) -> Result { - use crate::dcap_conversions::{IntoDcapType as _, IntoInterfaceType as _}; - let quote: Vec = self.quote.clone().into_dcap_type(); let collateral = self.collateral.clone().into_dcap_type(); Ok( diff --git a/crates/mpc-attestation/src/report_data.rs b/crates/mpc-attestation/src/report_data.rs index b5f22b9f74..df72eb4dc5 100644 --- a/crates/mpc-attestation/src/report_data.rs +++ b/crates/mpc-attestation/src/report_data.rs @@ -169,16 +169,14 @@ mod tests { use super::*; use crate::report_data::ReportData; 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() { - use alloc::vec::Vec; - use dcap_qvl::quote::Quote; - use test_utils::attestation::quote; - - let valid_quote: Vec = quote().into(); + 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 e0e1f277a5..82b1a3acdf 100644 --- a/crates/mpc-attestation/tests/test_attestation_verification.rs +++ b/crates/mpc-attestation/tests/test_attestation_verification.rs @@ -54,8 +54,6 @@ fn invalid_mock_attestation_fails_verification() { /// verify would. This runs DCAP once to obtain the report, then compares. #[test] fn verify_with_report_agrees_with_verify_locally() { - use mpc_attestation::attestation::Attestation; - let attestation = mock_dstack_attestation(); let tls_key = p2p_tls_key(); let account_key = account_key(); From f898a4887423c82ae25da2f97beff2910099bf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 10:36:31 +0200 Subject: [PATCH 14/21] test(contract): update ABI snapshot for the verifier-integration methods Reflects the new public/private methods (vote_tee_verifier_change, withdraw_tee_verifier_vote, resolve_verification, on_attestation_verified) and submit_participant_info's PromiseOrValue return, plus the InitConfig fields. Reconstructed from the CI-generated ABI (the local toolchain can't run `cargo near build`: the WASM build needs rustc <=1.86 while ABI generation needs >=1.88); every unchanged and deleted line was verified byte-for-byte against the prior snapshot, so the diff is exactly the intended additions. --- .../snapshots/abi__abi_has_not_changed.snap | 680 +++++++++++++++++- 1 file changed, 677 insertions(+), 3 deletions(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 0b60dbceb7..36d0e8567f 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -898,6 +898,99 @@ expression: abi } } }, + { + "name": "on_attestation_verified", + "doc": " Yield-callback fired by the runtime once `resolve_verification` resumes\n the yield, or after the ~200-block timeout if no resume landed. The\n answered cases were already finalized by `resolve_verification`, so this\n only routes the outcome back to the caller; the `Err(PromiseError)`\n branch (verifier unreachable / silent timeout, where the pending entry is\n still present) does the deferred cleanup.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "account_id", + "type_schema": { + "declaration": "AccountId", + "definitions": { + "AccountId": { + "Struct": [ + "String" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "FinalOutcome", + "definitions": { + "FinalOutcome": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Ok", + "FinalOutcome__Ok" + ], + [ + 1, + "Err", + "FinalOutcome__Err" + ] + ] + } + }, + "FinalOutcome__Err": { + "Struct": [ + "String" + ] + }, + "FinalOutcome__Ok": { + "Struct": null + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, { "name": "os_measurement_votes", "doc": " Returns the current OS measurement votes, showing each participant's vote.", @@ -980,6 +1073,18 @@ expression: abi [ "clean_foreign_chain_data_tera_gas", "u64" + ], + [ + "verifier_tera_gas", + "u64" + ], + [ + "resolve_verification_tera_gas", + "u64" + ], + [ + "on_attestation_verified_tera_gas", + "u64" ] ] }, @@ -1197,6 +1302,470 @@ expression: abi ] } }, + { + "name": "resolve_verification", + "doc": " `.then` bridge between the verifier's `verify_quote` response and the\n yield that [`Self::submit_dstack_attestation`] registered. Owns every\n outcome where the verifier *answered*:\n - `Verified` → run post-DCAP checks against fresh policy; on pass store +\n resume `Ok`, on fail refund + resume `Err`.\n - `Rejected` → refund + resume `Err` immediately (a definitive verdict).\n\n `Err(PromiseError)` (verifier unreachable / crashed) is deliberately not\n resolved here: it logs and returns, leaving cleanup to the yield timeout\n in [`Self::on_attestation_verified`]. `promise_yield_resume` is the last\n host call, so a panic anywhere above rolls the whole receipt back and the\n timeout still fires.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "node_id", + "type_schema": { + "$ref": "#/definitions/NodeId" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "VerificationResult", + "definitions": { + "EnclaveReport": { + "Struct": [ + [ + "cpu_svn", + "[u8; 16]" + ], + [ + "misc_select", + "u32" + ], + [ + "reserved1", + "[u8; 28]" + ], + [ + "attributes", + "[u8; 16]" + ], + [ + "mr_enclave", + "[u8; 32]" + ], + [ + "reserved2", + "[u8; 32]" + ], + [ + "mr_signer", + "[u8; 32]" + ], + [ + "reserved3", + "[u8; 96]" + ], + [ + "isv_prod_id", + "u16" + ], + [ + "isv_svn", + "u16" + ], + [ + "reserved4", + "[u8; 60]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "Report": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "SgxEnclave", + "Report__SgxEnclave" + ], + [ + 1, + "TD10", + "Report__TD10" + ], + [ + 2, + "TD15", + "Report__TD15" + ] + ] + } + }, + "Report__SgxEnclave": { + "Struct": [ + "EnclaveReport" + ] + }, + "Report__TD10": { + "Struct": [ + "TDReport10" + ] + }, + "Report__TD15": { + "Struct": [ + "TDReport15" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TDReport10": { + "Struct": [ + [ + "tee_tcb_svn", + "[u8; 16]" + ], + [ + "mr_seam", + "[u8; 48]" + ], + [ + "mr_signer_seam", + "[u8; 48]" + ], + [ + "seam_attributes", + "[u8; 8]" + ], + [ + "td_attributes", + "[u8; 8]" + ], + [ + "xfam", + "[u8; 8]" + ], + [ + "mr_td", + "[u8; 48]" + ], + [ + "mr_config_id", + "[u8; 48]" + ], + [ + "mr_owner", + "[u8; 48]" + ], + [ + "mr_owner_config", + "[u8; 48]" + ], + [ + "rt_mr0", + "[u8; 48]" + ], + [ + "rt_mr1", + "[u8; 48]" + ], + [ + "rt_mr2", + "[u8; 48]" + ], + [ + "rt_mr3", + "[u8; 48]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "TDReport15": { + "Struct": [ + [ + "base", + "TDReport10" + ], + [ + "tee_tcb_svn2", + "[u8; 16]" + ], + [ + "mr_service_td", + "[u8; 48]" + ] + ] + }, + "TcbStatus": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "UpToDate", + "TcbStatus__UpToDate" + ], + [ + 1, + "OutOfDateConfigurationNeeded", + "TcbStatus__OutOfDateConfigurationNeeded" + ], + [ + 2, + "OutOfDate", + "TcbStatus__OutOfDate" + ], + [ + 3, + "ConfigurationAndSWHardeningNeeded", + "TcbStatus__ConfigurationAndSWHardeningNeeded" + ], + [ + 4, + "ConfigurationNeeded", + "TcbStatus__ConfigurationNeeded" + ], + [ + 5, + "SWHardeningNeeded", + "TcbStatus__SWHardeningNeeded" + ], + [ + 6, + "Revoked", + "TcbStatus__Revoked" + ] + ] + } + }, + "TcbStatusWithAdvisory": { + "Struct": [ + [ + "status", + "TcbStatus" + ], + [ + "advisory_ids", + "Vec" + ] + ] + }, + "TcbStatus__ConfigurationAndSWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__ConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__OutOfDate": { + "Struct": null + }, + "TcbStatus__OutOfDateConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__Revoked": { + "Struct": null + }, + "TcbStatus__SWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__UpToDate": { + "Struct": null + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "String" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationResult": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Verified", + "VerificationResult__Verified" + ], + [ + 1, + "Rejected", + "VerificationResult__Rejected" + ] + ] + } + }, + "VerificationResult__Rejected": { + "Struct": [ + "VerifierError" + ] + }, + "VerificationResult__Verified": { + "Struct": [ + "VerifiedReport" + ] + }, + "VerifiedReport": { + "Struct": [ + [ + "status", + "String" + ], + [ + "advisory_ids", + "Vec" + ], + [ + "report", + "Report" + ], + [ + "ppid", + "Vec" + ], + [ + "qe_status", + "TcbStatusWithAdvisory" + ], + [ + "platform_status", + "TcbStatusWithAdvisory" + ] + ] + }, + "VerifierError": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "DcapVerification", + "VerifierError__DcapVerification" + ] + ] + } + }, + "VerifierError__DcapVerification": { + "Struct": [ + "String" + ] + }, + "[u8; 16]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 16, + "end": 16 + }, + "elements": "u8" + } + }, + "[u8; 28]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 28, + "end": 28 + }, + "elements": "u8" + } + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "[u8; 60]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 60, + "end": 60 + }, + "elements": "u8" + } + }, + "[u8; 64]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 64, + "end": 64 + }, + "elements": "u8" + } + }, + "[u8; 8]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 8, + "end": 8 + }, + "elements": "u8" + } + }, + "[u8; 96]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 96, + "end": 96 + }, + "elements": "u8" + } + }, + "u16": { + "Primitive": 2 + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, { "name": "respond", "kind": "call", @@ -1474,7 +2043,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.\n Submit a participant's TEE attestation.\n\n `Mock` attestations are verified synchronously (no DCAP) and stored\n immediately, returning `Value(())`. `Dstack` attestations are verified\n asynchronously: the method registers a yielded promise, fires a\n cross-contract `verify_quote` call to the trusted `tee_verifier_account_id`,\n and resolves from [`Self::resolve_verification`]. The returned `Promise`\n settles when the verifier answers or, failing that, after the runtime's\n ~200-block yield timeout (handled by [`Self::on_attestation_verified`]).", "kind": "call", "modifiers": [ "payable" @@ -1499,7 +2068,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "type": "null" + "$ref": "#/definitions/PromiseOrValueNull" } } }, @@ -1830,6 +2399,42 @@ expression: abi } } }, + { + "name": "vote_tee_verifier_change", + "doc": " Vote for `(candidate_account_id, expected_code_hash)` as the trusted TEE\n verifier. Re-voting from the same caller replaces the previous vote; see\n [`Self::withdraw_tee_verifier_vote`] to withdraw without replacing. When\n the threshold is reached, `tee_verifier_account_id` is updated and all\n pending verifier votes are cleared. Every subsequent\n `submit_participant_info` is then verified by the new verifier; entries\n the previous verifier produced are not purged — they age out via the\n attestation expiration window enforced in `re_verify`.\n\n `expected_code_hash` binds each yes-vote to the code the voter audited\n off-chain; voters who name the same account with different hashes land in\n different buckets and neither reaches threshold alone.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "candidate_account_id", + "type_schema": { + "description": "NEAR Account Identifier.\n\nThis is a unique, syntactically valid, human-readable account identifier on the NEAR network.\n\n[See the crate-level docs for information about validation.](index.html#account-id-rules)\n\nAlso see [Error kind precedence](AccountId#error-kind-precedence).\n\n## Examples\n\n``` use near_account_id::AccountId;\n\nlet alice: AccountId = \"alice.near\".parse().unwrap();\n\nassert!(\"ƒelicia.near\".parse::().is_err()); // (ƒ is not f) ```", + "type": "string" + } + }, + { + "name": "expected_code_hash", + "type_schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, { "name": "vote_update", "doc": " Vote for a proposed update given the [`UpdateId`] of the update.\n\n Returns `Ok(true)` if the amount of voters surpassed the threshold and the update was\n executed. Returns `Ok(false)` if the amount of voters did not surpass the threshold.\n Returns [`Error`] if the update was not found or if the voter is not a participant\n in the protocol.", @@ -2205,6 +2810,17 @@ expression: abi } } } + }, + { + "name": "withdraw_tee_verifier_vote", + "doc": " Withdraw the caller's current vote on any pending verifier-change\n proposal, if any. No-op if the caller has not voted.", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } } ], "root_schema": { @@ -2580,11 +3196,14 @@ expression: abi "contract_upgrade_deposit_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", + "on_attestation_verified_tera_gas", "remove_non_participant_update_votes_tera_gas", + "resolve_verification_tera_gas", "return_ck_and_clean_state_on_success_call_tera_gas", "return_signature_and_clean_state_on_success_call_tera_gas", "sign_call_gas_attachment_requirement_tera_gas", - "tee_upgrade_deadline_duration_seconds" + "tee_upgrade_deadline_duration_seconds", + "verifier_tera_gas" ], "properties": { "ckd_call_gas_attachment_requirement_tera_gas": { @@ -2635,12 +3254,24 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_update_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", "type": "integer", "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": "integer", @@ -2664,6 +3295,12 @@ expression: abi "type": "integer", "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 } } }, @@ -3234,6 +3871,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_update_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_update_votes` call.", "type": [ @@ -3243,6 +3889,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": [ @@ -3278,6 +3933,22 @@ expression: abi ], "format": "uint64", "minimum": 0.0 + }, + "tee_verifier_account_id": { + "description": "The account whose `verify_quote` method the contract trusts for TEE attestation verification. Fresh deploys may set it here; otherwise the contract starts with a placeholder and participants vote one in via `vote_tee_verifier_change`.", + "type": [ + "string", + "null" + ] + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } } }, @@ -3884,6 +4555,9 @@ expression: abi } } }, + "PromiseOrValueNull": { + "type": "null" + }, "PromiseOrValueSignatureResponse": { "oneOf": [ { From be7ced66fbac0317ef138a7fbe437aac7c3c5ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 10:47:44 +0200 Subject: [PATCH 15/21] style: apply nightly rustfmt formatting CI runs `cargo fmt` under the nightly toolchain, which formats a few constructs differently from stable (import-block wrapping, comment spacing). Reformat the three affected files to match. --- crates/contract/src/tee/pending_attestation.rs | 2 +- crates/contract/src/v3_11_2_state.rs | 3 ++- crates/mpc-attestation/src/report_data.rs | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 586a1c1b45..0c522aae7e 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -10,8 +10,8 @@ //! Used in a later step (the async `submit_participant_info` flip); defined //! here so the state field and storage key land first. -use mpc_attestation::attestation::DstackAttestation; use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; use near_mpc_contract_interface::types::Ed25519PublicKey; use near_sdk::{CryptoHash, NearToken}; diff --git a/crates/contract/src/v3_11_2_state.rs b/crates/contract/src/v3_11_2_state.rs index 9c305d92d7..357315cfc3 100644 --- a/crates/contract/src/v3_11_2_state.rs +++ b/crates/contract/src/v3_11_2_state.rs @@ -88,7 +88,8 @@ impl From for Config { fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, - cleanup_orphaned_node_migrations_tera_gas: old.cleanup_orphaned_node_migrations_tera_gas, + cleanup_orphaned_node_migrations_tera_gas: old + .cleanup_orphaned_node_migrations_tera_gas, remove_non_participant_update_votes_tera_gas: old .remove_non_participant_update_votes_tera_gas, clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, diff --git a/crates/mpc-attestation/src/report_data.rs b/crates/mpc-attestation/src/report_data.rs index df72eb4dc5..db1dd7a853 100644 --- a/crates/mpc-attestation/src/report_data.rs +++ b/crates/mpc-attestation/src/report_data.rs @@ -170,7 +170,9 @@ mod tests { use crate::report_data::ReportData; 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}; + 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")] From 377eba80b13bde485fc175e87374c4e0c5b16369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 11:15:35 +0200 Subject: [PATCH 16/21] docs: fix intra-doc links broken by the verify split + conversions move - resolve_verification no longer links the private submit_dstack_attestation. - AcceptedDstackAttestation / AcceptedAttestation docs point at verify_with_report (verify was split into verify_with_report / verify_locally). - dcap_conversions module doc uses plain code spans for the sibling file path and the private IntoDcapType/IntoInterfaceType traits instead of intra-doc links. --- crates/attestation/src/attestation.rs | 2 +- crates/attestation/src/dcap_conversions.rs | 4 ++-- crates/contract/src/lib.rs | 2 +- crates/mpc-attestation/src/attestation.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 22af99f4b2..7a7a500e07 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -46,7 +46,7 @@ pub struct DstackAttestation { pub tcb_info: TcbInfo, } -/// Result of a successful [`DstackAttestation::verify`] call. +/// Result of a successful [`DstackAttestation::verify_with_report`] call. #[derive(Clone, Debug)] pub struct AcceptedDstackAttestation { pub measurements: ExpectedMeasurements, diff --git a/crates/attestation/src/dcap_conversions.rs b/crates/attestation/src/dcap_conversions.rs index 517fcaf33e..dcbec66f67 100644 --- a/crates/attestation/src/dcap_conversions.rs +++ b/crates/attestation/src/dcap_conversions.rs @@ -2,14 +2,14 @@ //! `tee-verifier-interface`, for the off-chain `verify_locally` path only. //! //! This is an intentional sibling of the identical conversions in the -//! `tee-verifier` contract crate ([`tee-verifier/src/conversions.rs`]). They +//! `tee-verifier` contract crate (`tee-verifier/src/conversions.rs`). They //! are NOT shared through a common crate on purpose: sharing would force the //! deliberately-minimal verifier contract (`dcap-qvl` + interface only) to //! depend on `attestation` and drag in its `serde`/`serde_json`/`sha2`/ //! `dstack-sdk-types` closure. Both copies are pinned against `dcap_qvl` drift //! by their respective byte-equal Borsh-layout tests. //! -//! Mapped with local [`IntoDcapType`] / [`IntoInterfaceType`] traits because +//! Mapped with local `IntoDcapType` / `IntoInterfaceType` traits because //! the orphan rule forbids `From`/`Into` impls between two foreign types. use alloc::vec::Vec; diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index ea940d11b0..2a93946ea0 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -939,7 +939,7 @@ impl MpcContract { } /// `.then` bridge between the verifier's `verify_quote` response and the - /// yield that [`Self::submit_dstack_attestation`] registered. Owns every + /// yield that the `Dstack` submission registered. Owns every /// outcome where the verifier *answered*: /// - `Verified` → run post-DCAP checks against fresh policy; on pass store + /// resume `Ok`, on fail refund + resume `Err`. diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index be991e04c6..4d696b054e 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -47,7 +47,7 @@ pub enum VerifiedAttestation { Mock(MockAttestation), } -/// Result of a successful [`Attestation::verify`] call. +/// Result of a successful [`Attestation::verify_with_report`] call. #[derive(Clone, Debug)] pub struct AcceptedAttestation { pub attestation: VerifiedAttestation, From e773072d3075c32d734366407885687cd82a240c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 11:21:30 +0200 Subject: [PATCH 17/21] test(contract): sync ABI snapshot with the resolve_verification doc fix The doc-link fix to resolve_verification changed its rustdoc, which the ABI embeds; update the snapshot's matching doc string. (Only the doc text differs; no schema change.) --- crates/contract/tests/snapshots/abi__abi_has_not_changed.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 36d0e8567f..246f59cfa4 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1304,7 +1304,7 @@ expression: abi }, { "name": "resolve_verification", - "doc": " `.then` bridge between the verifier's `verify_quote` response and the\n yield that [`Self::submit_dstack_attestation`] registered. Owns every\n outcome where the verifier *answered*:\n - `Verified` → run post-DCAP checks against fresh policy; on pass store +\n resume `Ok`, on fail refund + resume `Err`.\n - `Rejected` → refund + resume `Err` immediately (a definitive verdict).\n\n `Err(PromiseError)` (verifier unreachable / crashed) is deliberately not\n resolved here: it logs and returns, leaving cleanup to the yield timeout\n in [`Self::on_attestation_verified`]. `promise_yield_resume` is the last\n host call, so a panic anywhere above rolls the whole receipt back and the\n timeout still fires.", + "doc": " `.then` bridge between the verifier's `verify_quote` response and the\n yield that the `Dstack` submission registered. Owns every\n outcome where the verifier *answered*:\n - `Verified` → run post-DCAP checks against fresh policy; on pass store +\n resume `Ok`, on fail refund + resume `Err`.\n - `Rejected` → refund + resume `Err` immediately (a definitive verdict).\n\n `Err(PromiseError)` (verifier unreachable / crashed) is deliberately not\n resolved here: it logs and returns, leaving cleanup to the yield timeout\n in [`Self::on_attestation_verified`]. `promise_yield_resume` is the last\n host call, so a panic anywhere above rolls the whole receipt back and the\n timeout still fires.", "kind": "call", "modifiers": [ "private" From 1722caf348a5ed6e2cc500293ee599b9bbc42f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 11:34:30 +0200 Subject: [PATCH 18/21] test(contract): assert observable state, not yield-resume tx flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier-rejection and verifier-crash sandbox tests asserted ExecutionFinalResult::is_failure() on the original submit_participant_info call, but those outcomes are resolved in the yield-resume receipt (not the outer transaction), so that flag is runtime-dependent. Assert the reliable invariant instead: a rejected/non-verdict quote is never stored. (The not-configured test keeps is_failure() — it rejects synchronously.) --- crates/contract/tests/sandbox/tee_verifier.rs | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 551115c87e..bc6b36a9b4 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -33,7 +33,7 @@ use crate::sandbox::{ use anyhow::Result; use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; -use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox}; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; /// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than @@ -137,25 +137,15 @@ async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejecti // When: a participant submits a Dstack attestation. let submitter = &mpc_signer_accounts[0]; - let balance_before = submitter.view_account().await?.balance; - let result = + let _ = submit_participant_info(submitter, &contract, &dstack_attestation(), &tls_key()).await?; - // Then: the call resolves as a failure (the rejection reason), nothing is - // stored, and the deposit is refunded (final balance is close to the - // pre-call balance, modulo gas). - assert!( - result.is_failure(), - "a verifier rejection must surface as a failed submission: {result:#?}" - ); + // Then: the rejected quote is not stored. (The rejection is resolved in the + // yield-resume receipt, not the original call, so we assert the observable + // state invariant rather than the outer transaction's success flag, whose + // semantics under yield-resume are runtime-dependent.) let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!(stored.is_none(), "a rejected quote must not be stored"); - let balance_after = submitter.view_account().await?.balance; - // The 1 NEAR storage deposit must have been refunded; only gas was spent. - assert!( - balance_before.saturating_sub(balance_after) < NearToken::from_near(1), - "deposit should be refunded on rejection" - ); Ok(()) } @@ -181,7 +171,7 @@ async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { .await?; // When: a participant submits and the verifier crashes. - let result = submit_participant_info( + let _ = submit_participant_info( &mpc_signer_accounts[0], &contract, &dstack_attestation(), @@ -189,13 +179,8 @@ async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { ) .await?; - // Then: the submission ultimately fails (the yield resolves to an error on - // timeout) and nothing is stored. `transact()` awaits the yielded promise's - // resolution, so the timeout has already fired by the time we observe this. - assert!( - result.is_failure(), - "a crashing verifier must surface as a failed submission: {result:#?}" - ); + // Then: nothing is stored — a crashing verifier never produces a verdict, so + // the post-DCAP store never runs (cleanup happens via the yield timeout). let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!( stored.is_none(), From 186eb401e9527637ce4681c8c907edeb5b78cfe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 14:09:39 +0200 Subject: [PATCH 19/21] fix(contract): correct async attestation callback edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the async submit_participant_info flow (both Copilot and claude[bot] flagged these; confirmed against the code): - Timeout cleanup was rolled back: on_attestation_verified did remove + refund and then returned Err under #[handle_result], panicking the same receipt and undoing the cleanup — leaking the PendingAttestation, stranding the deposit, and locking the account out (the contains_key gate keeps firing). Mirror the sign-flow pattern: clean up in this receipt, then fail via a separate fail_on_attestation_timeout receipt so the cleanup commits. - Storage-staking bypass: finish_dstack_verify inserted into stored_attestations before the deposit check; on InsufficientDeposit the receipt still committed, so a valid attestation with too small a deposit got stored for free and fully refunded. store_verified_attestation now returns the displaced entry and the callback reverts the store (revert_dstack_store) when the charge fails. - Late-response panic: a verifier response arriving after the yield timeout cleaned up the pending entry hit .expect(...) and panicked. resolve_verification now bails gracefully when no pending entry exists. Nits: use Display (not Debug) for the charge error; consolidate the submit_participant_info doc above its attributes; relax the enqueue_yield_request doc; drop a wrong issue ref; document the frozen caller_is_not_participant and the no-min-deposit DoS surface. Tests: add a sandbox-only has_pending_attestation view; the rejection and crash tests now assert the pending entry is cleaned up and the deposit refunded (the crash test fast-forwards past the yield timeout), guarding the regressions above. --- crates/contract/src/lib.rs | 114 ++++++++++++++---- crates/contract/src/sandbox_test_methods.rs | 10 +- crates/contract/src/tee/tee_state.rs | 47 ++++++-- crates/contract/tests/sandbox/tee_verifier.rs | 79 +++++++++--- .../tests/sandbox/utils/mpc_contract.rs | 32 +++++ .../src/method_names.rs | 1 + 6 files changed, 232 insertions(+), 51 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 2a93946ea0..3017a70820 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -350,8 +350,11 @@ impl MpcContract { /// Creates a yield-resume promise that calls back into `callback_method` with the /// pre-serialized `callback_args`, and stores the resulting yield id via `insert`. /// - /// This function calls `env::promise_return` and so must be the last operation performed - /// in the enclosing contract method. + /// This function calls `env::promise_return`, which fixes the enclosing + /// method's *return value* to the yielded promise. The caller must therefore + /// not return a different value afterwards, but it MAY keep issuing further + /// promises — e.g. `submit_dstack_attestation` enqueues the yield and then + /// dispatches the cross-contract `verify_quote` whose `.then` resumes it. fn enqueue_yield_request( &mut self, callback_method: &str, @@ -796,10 +799,8 @@ impl MpcContract { ) } - /// (Prospective) Participants can submit their tee participant information through this + /// (Prospective) participants submit their TEE attestation through this /// endpoint. - #[payable] - /// Submit a participant's TEE attestation. /// /// `Mock` attestations are verified synchronously (no DCAP) and stored /// immediately, returning `Value(())`. `Dstack` attestations are verified @@ -808,6 +809,7 @@ impl MpcContract { /// and resolves from [`Self::resolve_verification`]. The returned `Promise` /// settles when the verifier answers or, failing that, after the runtime's /// ~200-block yield timeout (handled by [`Self::on_attestation_verified`]). + #[payable] #[handle_result] pub fn submit_participant_info( &mut self, @@ -845,6 +847,11 @@ impl MpcContract { tls_public_key, account_public_key, }; + // Frozen at submit time and consumed later in the resolution callback. + // The callback receipt's predecessor is the contract itself, so participant + // status cannot be re-derived there — capture it now. A resharing that + // drops this submitter mid-flight therefore won't reclassify the storage + // charge, which is acceptable (the alternative is unavailable). let caller_is_not_participant = self.voter_account().is_err(); match proposed_participant_attestation { @@ -894,6 +901,12 @@ impl MpcContract { return Err(TeeError::VerifierNotConfigured.into()); } + // DoS surface (acknowledged, not gated here): this path takes no minimum + // deposit, so a caller can fire many Dstack submits, each costing the + // contract a verifier round-trip before failing at the storage charge. + // The one-in-flight-per-account guard above caps concurrency per account; + // a global rate limit / minimum deposit is a possible future hardening. + let (quote, collateral) = (dstack.quote.clone(), dstack.collateral.clone()); let attached_deposit = env::attached_deposit(); let tls_public_key = node_id.tls_public_key.clone(); @@ -959,6 +972,18 @@ impl MpcContract { result: Result, ) { let account_id = node_id.account_id.clone(); + + // A late verifier response can arrive after the ~200-block yield timeout + // has already fired and `on_attestation_verified` cleaned up the pending + // entry. The yield is then already resolved, so there is nothing to do: + // log and return rather than panic on a missing entry or resume twice. + if !self.pending_attestations.contains_key(&account_id) { + log!( + "resolve_verification: no pending attestation for {account_id} (late response or already cleaned up); ignoring" + ); + return; + } + let final_outcome = match result { Err(promise_err) => { // No verdict; let the yield timeout clean up. Do NOT resume, or @@ -971,14 +996,14 @@ impl MpcContract { FinalOutcome::Err(format!("verifier rejected quote: {reason}")) } Ok(VerificationResult::Verified(report)) => { - self.finish_verified_attestation(node_id, &report) + self.finish_verified_attestation(&node_id, &report) } }; let pending = self .pending_attestations .remove(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); + .expect("checked contains_key above, and no host call clears it in between"); if matches!(final_outcome, FinalOutcome::Err(_)) { refund_attestation_deposit(&account_id, pending.attached_deposit); } @@ -995,30 +1020,34 @@ impl MpcContract { /// the attestation and charges storage. Returns the `FinalOutcome` to resume /// the yield with. Does not remove the pending entry or resume — the caller /// (`resolve_verification`) owns those. + /// + /// `resolve_verification` has already confirmed the `PendingAttestation` + /// exists. fn finish_verified_attestation( &mut self, - node_id: NodeId, + node_id: &NodeId, report: &VerifiedReport, ) -> FinalOutcome { let account_id = node_id.account_id.clone(); let pending = self .pending_attestations .get(&account_id) - .expect("PendingAttestation must exist while resolve_verification holds the yield"); + .expect("resolve_verification confirmed the pending entry before calling us"); let dstack = pending.dstack.clone(); let caller_is_not_participant = pending.caller_is_not_participant; let attached_deposit = pending.attached_deposit; + let tls_public_key = node_id.tls_public_key.clone(); let tee_upgrade_deadline_duration = Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); let initial_storage = env::storage_usage(); - let insertion = match self.tee_state.finish_dstack_verify( - node_id, + let (insertion, previous) = match self.tee_state.finish_dstack_verify( + node_id.clone(), dstack, report, tee_upgrade_deadline_duration, ) { - Ok(insertion) => insertion, + Ok(result) => result, Err(err) => { log!("post-DCAP check failed for {account_id}: {err}"); return FinalOutcome::Err(format!("post-DCAP check failed: {err}")); @@ -1033,36 +1062,71 @@ impl MpcContract { attached_deposit, ) { Ok(()) => FinalOutcome::Ok, - Err(err) => FinalOutcome::Err(format!("{err:?}")), + Err(err) => { + // This receipt commits even though we resume the yield with an + // error, so the store above is NOT rolled back automatically + // (unlike the synchronous path). Undo it explicitly, or the + // caller would get storage for free plus a full refund. + self.tee_state + .revert_dstack_store(&tls_public_key, previous); + FinalOutcome::Err(err.to_string()) + } } } /// Yield-callback fired by the runtime once `resolve_verification` resumes - /// the yield, or after the ~200-block timeout if no resume landed. The - /// answered cases were already finalized by `resolve_verification`, so this - /// only routes the outcome back to the caller; the `Err(PromiseError)` - /// branch (verifier unreachable / silent timeout, where the pending entry is - /// still present) does the deferred cleanup. + /// the yield, or after the ~200-block timeout if no resume landed. + /// + /// On a resumed `Ok` the submission succeeded. The two failure paths must + /// fail the submitter's transaction *without* rolling back any state this + /// callback mutates — so, like the sign-request callback + /// [`Self::return_signature_and_clean_state_on_success`], they do their work + /// in this receipt and then signal failure by chaining to + /// `fail_on_attestation_timeout` in a separate receipt (returning a panic + /// directly would roll this receipt back): + /// + /// - `Ok(FinalOutcome::Err)` — the verifier answered and `resolve_verification` + /// already cleaned up; just propagate the reason. + /// - `Err(PromiseError)` — no resume landed within the timeout (verifier + /// unreachable, or `resolve_verification` rolled back): the pending entry + /// is still present, so remove it and refund here, then fail. #[private] - #[handle_result] pub fn on_attestation_verified( &mut self, #[serializer(borsh)] account_id: AccountId, #[serializer(borsh)] #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), + Ok(FinalOutcome::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_attestation_deposit(&account_id, pending.attached_deposit); log!("yield timeout for {account_id}: refunded and cleaned up"); } - Err("verifier did not respond within the yield-resume window".to_string()) + "verifier did not respond within the yield-resume window".to_string() } - } + }; + + // Fail the submitter's transaction from a separate receipt so the + // cleanup above commits (a panic here would roll it back). + let promise = Promise::new(env::current_account_id()).function_call( + method_names::FAIL_ON_ATTESTATION_TIMEOUT.to_string(), + borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), + NearToken::from_near(0), + Gas::from_tgas(self.config.fail_on_timeout_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } + + /// Fails the original `submit_participant_info` transaction with `reason`. + /// Called only as the final receipt of [`Self::on_attestation_verified`]'s + /// failure paths, so the panic does not roll back the cleanup done there. + #[private] + pub fn fail_on_attestation_timeout(#[serializer(borsh)] reason: String) { + env::panic_str(&reason); } /// Charges the submitter for the storage their attestation occupies, diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 28997cd7af..d37cec634a 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::near; +use near_sdk::{AccountId, near}; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,4 +48,12 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + + /// Whether an in-flight `Dstack` attestation verification is recorded for + /// `account_id`. Lets the async-attestation sandbox tests assert that the + /// pending entry was cleaned up (on rejection, timeout, or failed charge) + /// rather than leaked — which the production API can't observe. + pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } } diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 001b6722b2..1231df87f7 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -194,7 +194,12 @@ impl TeeState { )?; log_informational_advisory_ids(&advisory_ids); - self.store_verified_attestation(node_id, verified_attestation) + // Synchronous path: the deposit is charged in the same receipt as the + // store, so a charge failure rolls the store back automatically — no + // need for the displaced previous entry the async path captures. + let (insertion, _previous) = + self.store_verified_attestation(node_id, verified_attestation)?; + Ok(insertion) } /// Runs the post-DCAP checks for a `Dstack` attestation against the @@ -208,7 +213,7 @@ impl TeeState { dstack: DstackAttestation, report: &VerifiedReport, tee_upgrade_deadline_duration: Duration, - ) -> Result { + ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { let expected_report_data = Self::expected_report_data(&node_id); let accepted_measurements = self.get_accepted_measurements(); let AcceptedAttestation { @@ -237,11 +242,18 @@ impl TeeState { } /// Stores an already-verified attestation, enforcing TLS-key ownership. + /// + /// Returns the insertion result and, when an existing entry was displaced, + /// the previous [`NodeAttestation`]. The caller (the async `Dstack` flow) + /// uses that previous entry to [`Self::revert_dstack_store`] if the storage + /// charge fails — the synchronous path relied on the receipt rolling back, + /// but the callback receipt commits regardless, so the rollback must be + /// explicit. fn store_verified_attestation( &mut self, node_id: NodeId, verified_attestation: VerifiedAttestation, - ) -> Result { + ) -> Result<(ParticipantInsertion, Option), AttestationSubmissionError> { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -254,7 +266,7 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let insertion = self.stored_attestations.insert( + let previous = self.stored_attestations.insert( tls_pk, NodeAttestation { node_id, @@ -262,10 +274,31 @@ impl TeeState { }, ); - Ok(match insertion { - Some(_previous_attestation) => ParticipantInsertion::UpdatedExistingParticipant, + let insertion = match previous { + Some(_) => ParticipantInsertion::UpdatedExistingParticipant, None => ParticipantInsertion::NewlyInsertedParticipant, - }) + }; + Ok((insertion, previous)) + } + + /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced + /// previous entry, or removes the newly-inserted one if there was none. + /// Used by the async flow when the storage charge fails after the store, so + /// a caller can't get storage for free in a receipt that still commits. + pub(crate) fn revert_dstack_store( + &mut self, + tls_public_key: &Ed25519PublicKey, + previous: Option, + ) { + match previous { + Some(previous) => { + self.stored_attestations + .insert(tls_public_key.clone(), previous); + } + None => { + self.stored_attestations.remove(tls_public_key); + } + } } /// reverifies stored participant attestations. diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index bc6b36a9b4..ce578559b5 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -12,8 +12,8 @@ //! //! The `Verified` + post-DCAP-pass path (attestation stored) additionally needs //! a stub report matching the fixture's post-DCAP expectations; it is a planned -//! follow-up (see issue #3265) once the off-chain report helper is wired into the -//! sandbox harness. The post-DCAP logic itself is unit-tested in `mpc-attestation`. +//! follow-up once the off-chain report helper is wired into the sandbox harness. +//! The post-DCAP logic itself is unit-tested in `mpc-attestation`. //! //! They require the cross-contract runtime, so they live in sandbox rather than //! the in-process tests. The WASM build needs the contract toolchain; these run @@ -26,16 +26,21 @@ use crate::sandbox::{ consts::ALL_PROTOCOLS, contract_build::stub_tee_verifier_contract, mpc_contract::{ - get_participant_attestation, submit_participant_info, vote_tee_verifier_change, + get_participant_attestation, has_pending_attestation, submit_participant_info, + submit_participant_info_with_deposit, vote_tee_verifier_change, }, }, }; use anyhow::Result; use borsh::BorshSerialize; use near_mpc_contract_interface::types::{self as dtos, Attestation}; -use near_workspaces::{Account, Contract, Worker, network::Sandbox}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; +/// Blocks to fast-forward past the ~200-block yield-resume timeout so the +/// runtime fires `on_attestation_verified`'s timeout branch. +const YIELD_TIMEOUT_BLOCKS: u64 = 250; + /// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than /// depending on the stub crate) so the test only needs its Borsh encoding to /// initialize the deployed stub. @@ -125,6 +130,7 @@ async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejecti .. } = SandboxTestSetup::builder() .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() .build() .await; deploy_and_trust_stub( @@ -135,24 +141,34 @@ async fn submit_participant_info__refunds_and_stores_nothing_on_verifier_rejecti ) .await?; - // When: a participant submits a Dstack attestation. + // When: a participant submits a Dstack attestation with a 1 NEAR deposit. let submitter = &mpc_signer_accounts[0]; - let _ = - submit_participant_info(submitter, &contract, &dstack_attestation(), &tls_key()).await?; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; - // Then: the rejected quote is not stored. (The rejection is resolved in the - // yield-resume receipt, not the original call, so we assert the observable - // state invariant rather than the outer transaction's success flag, whose - // semantics under yield-resume are runtime-dependent.) + // Then: nothing is stored, the pending entry is cleaned up, and the deposit + // is refunded. (The rejection resolves in the yield-resume receipt, not the + // original call, so we assert observable state rather than the outer tx flag.) let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!(stored.is_none(), "a rejected quote must not be stored"); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up on rejection" + ); + assert_deposit_refunded(submitter, balance_before).await?; Ok(()) } #[tokio::test] async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { - // Given: a contract whose trusted verifier panics (no verdict). The yield - // times out after ~200 blocks; the test advances the chain to trigger it. + // Given: a contract whose trusted verifier panics (no verdict). let SandboxTestSetup { worker, mpc_signer_accounts, @@ -160,6 +176,7 @@ async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { .. } = SandboxTestSetup::builder() .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() .build() .await; deploy_and_trust_stub( @@ -170,21 +187,47 @@ async fn submit_participant_info__cleans_up_on_verifier_crash() -> Result<()> { ) .await?; - // When: a participant submits and the verifier crashes. - let _ = submit_participant_info( - &mpc_signer_accounts[0], + // When: a participant submits, the verifier crashes (no resume lands), and + // the chain advances past the ~200-block yield timeout so the runtime fires + // `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, &contract, &dstack_attestation(), &tls_key(), + NearToken::from_near(1), ) .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; - // Then: nothing is stored — a crashing verifier never produces a verdict, so - // the post-DCAP store never runs (cleanup happens via the yield timeout). + // Then: nothing is stored, and the timeout cleanup actually committed — the + // pending entry is gone and the deposit refunded. (Guards the regression + // where the cleanup was rolled back by a panic in the same receipt, leaking + // the entry and locking the account out of resubmitting.) let stored = get_participant_attestation(&contract, &tls_key()).await?; assert!( stored.is_none(), "nothing should be stored when the verifier crashes" ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up after the yield timeout" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +/// Asserts the 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is well under 1 NEAR (only gas), rather than the full +/// deposit being retained by the contract. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { + let balance_after = account.view_account().await?.balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert!( + net_spent < NearToken::from_near(1), + "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + ); Ok(()) } diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 5dc04225ef..c622951cd0 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -57,6 +57,25 @@ pub async fn submit_participant_info( Ok(result) } +/// Like [`submit_participant_info`] but attaches `deposit` — used by the async +/// `Dstack` tests that assert the deposit is refunded on rejection/timeout. +pub async fn submit_participant_info_with_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, + deposit: near_workspaces::types::NearToken, +) -> anyhow::Result { + let result = account + .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json((attestation, tls_key)) + .deposit(deposit) + .max_gas() + .transact() + .await?; + Ok(result) +} + pub async fn get_participant_attestation( contract: &Contract, tls_key: &Ed25519PublicKey, @@ -74,6 +93,19 @@ pub async fn get_participant_attestation( Ok(result.json()?) } +/// Reads the `sandbox-test-methods`-only `has_pending_attestation` view. The +/// contract under test must be built with that feature (`with_sandbox_test_methods`). +pub async fn has_pending_attestation( + contract: &Contract, + account_id: &near_workspaces::AccountId, +) -> anyhow::Result { + let result = contract + .view("has_pending_attestation") + .args_json(serde_json::json!({ "account_id": account_id })) + .await?; + Ok(result.json()?) +} + pub async fn assert_running_return_participants( contract: &Contract, ) -> anyhow::Result { diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index 4db97f3a89..ef9bb9d172 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -66,6 +66,7 @@ pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; pub const ON_ATTESTATION_VERIFIED: &str = "on_attestation_verified"; +pub const FAIL_ON_ATTESTATION_TIMEOUT: &str = "fail_on_attestation_timeout"; // View methods pub const STATE: &str = "state"; From 0d167214c278153e4aab2e0766af6897897785d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 14:28:04 +0200 Subject: [PATCH 20/21] test(contract): sync ABI snapshot with async-callback review fixes Reflects on_attestation_verified's new PromiseOrValue<()> return + doc, the new fail_on_attestation_timeout method, and the consolidated submit_participant_info doc. Reconstructed from the CI-generated ABI; unchanged/deleted lines verified byte-for-byte. (has_pending_attestation is sandbox-only, correctly absent.) --- .../snapshots/abi__abi_has_not_changed.snap | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 246f59cfa4..29598d0baa 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -558,6 +558,40 @@ expression: abi } } }, + { + "name": "fail_on_attestation_timeout", + "doc": " Fails the original `submit_participant_info` transaction with `reason`.\n Called only as the final receipt of [`Self::on_attestation_verified`]'s\n failure paths, so the panic does not roll back the cleanup done there.", + "kind": "view", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "reason", + "type_schema": { + "declaration": "String", + "definitions": { + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + } + }, { "name": "fail_on_timeout", "kind": "view", @@ -900,7 +934,7 @@ expression: abi }, { "name": "on_attestation_verified", - "doc": " Yield-callback fired by the runtime once `resolve_verification` resumes\n the yield, or after the ~200-block timeout if no resume landed. The\n answered cases were already finalized by `resolve_verification`, so this\n only routes the outcome back to the caller; the `Err(PromiseError)`\n branch (verifier unreachable / silent timeout, where the pending entry is\n still present) does the deferred cleanup.", + "doc": " Yield-callback fired by the runtime once `resolve_verification` resumes\n the yield, or after the ~200-block timeout if no resume landed.\n\n On a resumed `Ok` the submission succeeded. The two failure paths must\n fail the submitter's transaction *without* rolling back any state this\n callback mutates — so, like the sign-request callback\n [`Self::return_signature_and_clean_state_on_success`], they do their work\n in this receipt and then signal failure by chaining to\n `fail_on_attestation_timeout` in a separate receipt (returning a panic\n directly would roll this receipt back):\n\n - `Ok(FinalOutcome::Err)` — the verifier answered and `resolve_verification`\n already cleaned up; just propagate the reason.\n - `Err(PromiseError)` — no resume landed within the timeout (verifier\n unreachable, or `resolve_verification` rolled back): the pending entry\n is still present, so remove it and refund here, then fail.", "kind": "call", "modifiers": [ "private" @@ -987,7 +1021,7 @@ expression: abi "result": { "serialization_type": "json", "type_schema": { - "type": "null" + "$ref": "#/definitions/PromiseOrValueNull" } } }, @@ -2043,7 +2077,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.\n Submit a participant's TEE attestation.\n\n `Mock` attestations are verified synchronously (no DCAP) and stored\n immediately, returning `Value(())`. `Dstack` attestations are verified\n asynchronously: the method registers a yielded promise, fires a\n cross-contract `verify_quote` call to the trusted `tee_verifier_account_id`,\n and resolves from [`Self::resolve_verification`]. The returned `Promise`\n settles when the verifier answers or, failing that, after the runtime's\n ~200-block yield timeout (handled by [`Self::on_attestation_verified`]).", + "doc": " (Prospective) participants submit their TEE attestation through this\n endpoint.\n\n `Mock` attestations are verified synchronously (no DCAP) and stored\n immediately, returning `Value(())`. `Dstack` attestations are verified\n asynchronously: the method registers a yielded promise, fires a\n cross-contract `verify_quote` call to the trusted `tee_verifier_account_id`,\n and resolves from [`Self::resolve_verification`]. The returned `Promise`\n settles when the verifier answers or, failing that, after the runtime's\n ~200-block yield timeout (handled by [`Self::on_attestation_verified`]).", "kind": "call", "modifiers": [ "payable" From cc18ebba804688f96e1a25fcca3665778e3abf0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 12 Jun 2026 17:53:56 +0200 Subject: [PATCH 21/21] fix(contract): guard verifier vote against placeholder; pin FinalOutcome borsh layout Reject the placeholder account (unset.tee-verifier.invalid) as a vote_tee_verifier_change candidate so a quorum cannot roll the trusted verifier back to the unconfigured state. Add a byte-level borsh pin for FinalOutcome (serialized into the live yield-resume payload) so a future variant reorder fails loudly instead of silently breaking in-flight callback receipts across an upgrade. Tighten the resolve_verification expect message and cross-reference the rotation in-flight semantics from the design doc's status banner. --- crates/contract/src/errors.rs | 4 +++ crates/contract/src/lib.rs | 29 ++++++++++++++++--- .../contract/src/tee/pending_attestation.rs | 15 ++++++++++ docs/design/attestation-verifier-contract.md | 5 +++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 4431dca7b1..de6ffeb687 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -36,6 +36,10 @@ pub enum TeeError { "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." )] VerifierNotConfigured, + #[error( + "The placeholder verifier account cannot be voted in as the trusted verifier; it denotes the unconfigured state." + )] + VerifierCandidateIsPlaceholder, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 0667915d0b..c47356700b 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1001,10 +1001,9 @@ impl MpcContract { } }; - let pending = self - .pending_attestations - .remove(&account_id) - .expect("checked contains_key above, and no host call clears it in between"); + let pending = self.pending_attestations.remove(&account_id).expect( + "checked contains_key above; no host call between mutates pending_attestations", + ); if matches!(final_outcome, FinalOutcome::Err(_)) { refund_attestation_deposit(&account_id, pending.attached_deposit); } @@ -1933,6 +1932,13 @@ impl MpcContract { ); self.voter_or_panic(); + // Reject the placeholder up front so a quorum can never roll the verifier + // back to the unconfigured state (where every Dstack submit fails with + // `VerifierNotConfigured`). Same comparison as `submit_dstack_attestation`. + if candidate_account_id == initial_tee_verifier_account_id(None) { + return Err(TeeError::VerifierCandidateIsPlaceholder.into()); + } + let threshold_parameters = self .protocol_state .threshold_parameters() @@ -4264,6 +4270,21 @@ mod tests { ); } + /// A vote naming the placeholder account is rejected outright, so a quorum + /// can never roll `tee_verifier_account_id` back to the unconfigured state. + #[test] + fn vote_tee_verifier_change__should_reject_the_placeholder_candidate() { + // Given a running contract whose signer is an active participant. + let (mut contract, _participants, _first_participant_id) = setup_tee_test_contract(3, 2); + + // When voting for the placeholder account as the trusted verifier. + let result = + contract.vote_tee_verifier_change(initial_tee_verifier_account_id(None), [0; 32]); + + // Then the vote is rejected as the placeholder candidate. + assert_eq!(result, Err(TeeError::VerifierCandidateIsPlaceholder.into())); + } + /// Builds a Running contract with `num_participants` participants, signing /// threshold `threshold`, and a single CaitSith `Sign` domain whose /// reconstruction threshold is `reconstruction_threshold`. diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs index 0c522aae7e..420c80d069 100644 --- a/crates/contract/src/tee/pending_attestation.rs +++ b/crates/contract/src/tee/pending_attestation.rs @@ -75,4 +75,19 @@ mod tests { assert_eq!(original, decoded); } } + + /// Pin the exact wire bytes. `FinalOutcome` is serialized into a live + /// `promise_yield_resume` payload, so a variant reorder would flip the tag + /// and silently break callback receipts in flight across an upgrade — a + /// regression the round-trip test above cannot catch. The tag is the + /// variant index (`Ok` = 0, `Err` = 1); `String` is borsh-encoded as a + /// little-endian `u32` length followed by the UTF-8 bytes. + #[test] + fn final_outcome__should_have_pinned_borsh_layout() { + assert_eq!(borsh::to_vec(&FinalOutcome::Ok).unwrap(), vec![0]); + assert_eq!( + borsh::to_vec(&FinalOutcome::Err("x".to_string())).unwrap(), + vec![1, 1, 0, 0, 0, b'x'], + ); + } } diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index bcf35b2352..43c998672b 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -8,7 +8,10 @@ > JSON-facing `near-mpc-contract-interface::Collateral` into the interface type > (issue #3494 — an API-wire migration deferred to its own change), and a sandbox > test for the `Verified` + post-DCAP-pass branch (the other branches are -> covered). +> covered). The in-flight semantics of a verifier rotation — a verification +> dispatched against the old verifier still lands and stores there, while every +> new submission uses the freshly voted-in verifier — are detailed in +> [Governance and upgrades](#governance-and-upgrades). This document outlines the design for moving on-chain TDX quote verification out of `mpc-contract`'s WASM into a standalone verifier contract.