From a0e4c88df90415ae661c6a9d9048ff56c1a7b936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 16 Jun 2026 16:28:44 +0200 Subject: [PATCH 01/18] refactor(attestation): split DCAP verification behind a local-verify feature Separate Intel TDX DCAP cryptographic verification from the post-DCAP policy checks so the two can run in different places, and make dcap-qvl an optional dependency. Attestation::verify is split into: - verify_with_report: pure post-DCAP checks against an already-produced VerifiedReport, no dcap-qvl - verify_locally: full local DCAP + post-DCAP, behind the new local-verify feature (used off-chain by node, tee-authority, attestation-cli) - verify_mock_only: the Mock path, always compiled dcap-qvl moves behind local-verify, out of the attestation crate's default dependency graph. A new dcap_conversions module translates between the tee-verifier-interface DTOs and dcap-qvl types, pinned by byte-equal borsh-layout tests. The duplicate attestation Collateral and QuoteBytes newtypes collapse into re-exports of the tee-verifier-interface types (single source of truth); the interface crate gains an off-by-default serde feature for the node's /public_data payload. This is groundwork: mpc-contract's behavior is unchanged. Its synchronous attestation path now calls verify_locally (a byte-identical replacement for the old verify), so it still links dcap-qvl for now. Routing DCAP through a separate verifier contract, and dropping dcap-qvl from mpc-contract, is a follow-up. --- 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 | 114 ++++-- 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 | 2 +- crates/contract/src/dto_mapping.rs | 15 +- crates/contract/src/tee/tee_state.rs | 2 +- crates/mpc-attestation/Cargo.toml | 11 + crates/mpc-attestation/src/attestation.rs | 259 ++++++++++---- crates/mpc-attestation/src/lib.rs | 2 + crates/mpc-attestation/src/report_data.rs | 13 +- .../tests/test_attestation_verification.rs | 76 +++- 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 | 25 +- 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, 847 insertions(+), 266 deletions(-) create mode 100644 crates/attestation/src/dcap_conversions.rs diff --git a/Cargo.lock b/Cargo.lock index e2a1234c61..4a8ca52b4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,7 @@ name = "attestation" version = "3.12.0" dependencies = [ "assert_matches", + "attestation", "borsh", "dcap-qvl", "derive_more 2.1.1", @@ -986,6 +987,7 @@ dependencies = [ "serde_json", "serde_with", "sha2 0.10.9", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", ] @@ -5735,11 +5737,13 @@ dependencies = [ "hex", "include-measurements", "launcher-interface", + "mpc-attestation", "mpc-primitives", "serde", "serde_json", "sha2 0.10.9", "sha3", + "tee-verifier-interface", "test-utils", ] @@ -11107,6 +11111,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..7a7a500e07 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -17,6 +17,10 @@ use core::fmt; use derive_more::Constructor; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256, Sha384}; +use tee_verifier_interface::{TDReport10, VerifiedReport}; + +#[cfg(feature = "local-verify")] +use crate::dcap_conversions::{IntoDcapType as _, IntoInterfaceType as _}; /// Expected TCB status for a successfully verified TEE quote. const EXPECTED_QUOTE_STATUS: &str = "UpToDate"; @@ -30,6 +34,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, @@ -37,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, @@ -123,33 +132,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. + /// + /// 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. /// - /// On success, returns the matched measurements along with any informational - /// advisory IDs surfaced alongside an `UpToDate` TCB status. - pub fn verify( + /// `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 +169,43 @@ 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 { + 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 +284,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 +307,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 +332,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 +386,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 +529,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 +554,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..dcbec66f67 --- /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 72553d8ab4..8a2f7c4eb2 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -87,7 +87,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true } +mpc-attestation = { workspace = true, features = ["local-verify"] } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 150d67f798..146a7ccab8 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -10,7 +10,7 @@ use mpc_attestation::{ Attestation, DstackAttestation, ExpectedMeasurements, Measurements, MockAttestation, VerifiedAttestation, }, - collateral::{Collateral, QuoteCollateralV3}, + collateral::Collateral, tcb_info::{EventLog, HexBytes, TcbInfo}, }; use near_mpc_contract_interface::types as dtos; @@ -124,7 +124,9 @@ impl IntoContractType for dtos::Collateral { pck_certificate_chain, } = self; - Collateral::from(QuoteCollateralV3 { + // TODO(#3494): drop this conversion once `dtos::DstackAttestation` + // carries `tee_verifier_interface::Collateral` directly. + Collateral { pck_crl_issuer_chain, root_ca_crl: root_ca_crl.into(), pck_crl: pck_crl.into(), @@ -135,7 +137,7 @@ impl IntoContractType for dtos::Collateral { qe_identity, qe_identity_signature: qe_identity_signature.into(), pck_certificate_chain, - }) + } } } @@ -329,8 +331,9 @@ impl IntoInterfaceType for DstackAttestation { impl IntoInterfaceType for Collateral { fn into_dto_type(self) -> dtos::Collateral { - // Collateral is a newtype wrapper around QuoteCollateralV3 - let QuoteCollateralV3 { + // TODO(#3494): drop this conversion once `dtos` carries the interface + // `Collateral` directly. + let Collateral { pck_crl_issuer_chain, root_ca_crl, pck_crl, @@ -341,7 +344,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..1c607313d5 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -159,7 +159,7 @@ impl TeeState { 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..d6460c2185 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -8,6 +8,11 @@ edition = { workspace = true } abi = ["borsh/unstable__schema", "mpc-primitives/abi", "attestation/borsh-schema"] dstack-conversions = ["attestation/dstack-conversions"] test-utils = ["attestation/test-utils"] +# Enables `Attestation::verify_locally` (full local DCAP + post-DCAP +# verification), forwarding to `attestation/local-verify` which pulls in +# `dcap-qvl`. Used off-chain (node, tee-authority, attestation-cli) and, for +# now, by the contract's synchronous attestation path. +local-verify = ["attestation/local-verify"] [dependencies] attestation = { workspace = true } @@ -21,10 +26,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..1f1b35c903 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -11,6 +11,7 @@ use attestation::{ use include_measurements::include_measurements; use mpc_primitives::hash::{LauncherDockerComposeHash, NodeImageHash}; +use tee_verifier_interface::VerifiedReport; use borsh::{BorshDeserialize, BorshSerialize}; use launcher_interface::MPC_IMAGE_HASH_EVENT; @@ -40,7 +41,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, @@ -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,208 @@ 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, + ), + } + } + + /// 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`). + /// + /// 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..db1dd7a853 100644 --- a/crates/mpc-attestation/src/report_data.rs +++ b/crates/mpc-attestation/src/report_data.rs @@ -168,14 +168,17 @@ impl From for ::attestation::report_data::ReportData { mod tests { use super::*; use crate::report_data::ReportData; - use alloc::vec::Vec; - use dcap_qvl::quote::Quote; - use test_utils::attestation::{account_key, p2p_tls_key, quote}; + use test_utils::attestation::{account_key, p2p_tls_key}; + #[cfg(feature = "local-verify")] + use { + alloc::vec::Vec, dcap_qvl::quote::Quote, test_utils::attestation::quote as quote_fixture, + }; + // Parses a real quote with `dcap-qvl`, so it is off-chain only. + #[cfg(feature = "local-verify")] #[test] fn test_from_str_valid() { - let valid_quote: Vec = - serde_json::from_str(&serde_json::to_string("e()).unwrap()).unwrap(); + let valid_quote: Vec = quote_fixture().into(); let quote = Quote::parse(&valid_quote).unwrap(); let td_report = quote.report.as_td10().expect("Should be a TD 1.0 report"); diff --git a/crates/mpc-attestation/tests/test_attestation_verification.rs b/crates/mpc-attestation/tests/test_attestation_verification.rs index ad205ace82..82b1a3acdf 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,65 @@ 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() { + 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 +114,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 +148,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 +181,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 +202,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 +241,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 +285,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 +319,7 @@ fn validated_dstack_attestation_passes_reverification_with_superset_measurements let allowed_launcher_hashes = [launcher_compose_digest()]; let validated = attestation - .verify( + .verify_locally( report_data.into(), creation_time, &allowed_mpc_hashes, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 0392022074..6e3172af73 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -37,7 +37,7 @@ itertools = { workspace = true } k256 = { workspace = true } launcher-interface = { workspace = true } lru = { workspace = true } -mpc-attestation = { workspace = true } +mpc-attestation = { workspace = true, features = ["local-verify"] } mpc-node-config = { workspace = true } mpc-primitives = { workspace = true } mpc-tls = { workspace = true } diff --git a/crates/node/src/tee/remote_attestation.rs b/crates/node/src/tee/remote_attestation.rs index caaf85824d..ef099a8470 100644 --- a/crates/node/src/tee/remote_attestation.rs +++ b/crates/node/src/tee/remote_attestation.rs @@ -103,7 +103,7 @@ fn validate_remote_attestation( .unwrap() .as_secs(); attestation - .verify( + .verify_locally( expected_report_data.into(), now, allowed_docker_image_hashes, diff --git a/crates/node/src/trait_extensions/convert_to_contract_dto.rs b/crates/node/src/trait_extensions/convert_to_contract_dto.rs index 005ac1d833..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..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`, @@ -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 2bc5d9921a047d1255c1b50640f937eff02722e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 16 Jun 2026 16:42:46 +0200 Subject: [PATCH 02/18] docs(attestation): fix verify_locally docs to match PR behavior; rename test; drop a clone Address self-review/Copilot/claude[bot] doc-drift nits on the DCAP split: - verify_locally docs no longer claim mpc-contract calls the verifier contract for DCAP; the contract calls verify_locally today, and the verifier-contract switch is a follow-up - drop the stale 'now 1 day' note on MAX_COLLATERAL_AGE; the contract expiry constant is still 7 days - rename the new integration test to the mandated __should_ form - dcap_report borrows the quote bytes instead of cloning them --- crates/attestation/src/attestation.rs | 13 ++++++++----- crates/mpc-attestation/src/attestation.rs | 8 +++++--- .../tests/test_attestation_verification.rs | 3 ++- crates/tee-authority/src/tee_authority.rs | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 7a7a500e07..20a45bd553 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -174,9 +174,10 @@ impl DstackAttestation { /// /// 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. + /// attestation end-to-end. `mpc-contract` also calls this today (it enables + /// `local-verify`); a planned follow-up moves the DCAP step into a separate + /// verifier contract, after which the contract will call + /// [`verify_with_report`](Self::verify_with_report) directly instead. #[cfg(feature = "local-verify")] pub fn verify_locally( &self, @@ -197,10 +198,12 @@ impl DstackAttestation { /// ([`verify_with_report`](Self::verify_with_report)). #[cfg(feature = "local-verify")] pub fn dcap_report(&self, timestamp_seconds: u64) -> Result { - let quote: Vec = self.quote.clone().into_dcap_type(); + // Borrow the quote bytes directly (`QuoteBytes` is a `pub Vec`); the + // collateral clone is unavoidable since `QuoteCollateralV3` owns its + // fields and `into_dcap_type` consumes `self`. let collateral = self.collateral.clone().into_dcap_type(); Ok( - dcap_qvl::verify::verify("e, &collateral, timestamp_seconds) + dcap_qvl::verify::verify(&self.quote.0, &collateral, timestamp_seconds) .map_err(|e| VerificationError::DcapVerification(e.to_string()))? .into_interface_type(), ) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 1f1b35c903..4966e7be8e 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -234,9 +234,11 @@ impl Attestation { /// 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). + /// Used by the node, `tee-authority`, and `attestation-cli`. `mpc-contract` + /// also calls this today (it enables `local-verify`); a planned follow-up + /// moves the DCAP step into a separate verifier contract, after which the + /// contract will call [`verify_with_report`](Self::verify_with_report) + /// directly instead. #[cfg(feature = "local-verify")] pub fn verify_locally( &self, diff --git a/crates/mpc-attestation/tests/test_attestation_verification.rs b/crates/mpc-attestation/tests/test_attestation_verification.rs index 82b1a3acdf..9176808776 100644 --- a/crates/mpc-attestation/tests/test_attestation_verification.rs +++ b/crates/mpc-attestation/tests/test_attestation_verification.rs @@ -53,7 +53,8 @@ fn invalid_mock_attestation_fails_verification() { /// 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() { +#[expect(non_snake_case)] +fn verify_with_report__should_agree_with_verify_locally() { let attestation = mock_dstack_attestation(); let tls_key = p2p_tls_key(); let account_key = account_key(); diff --git a/crates/tee-authority/src/tee_authority.rs b/crates/tee-authority/src/tee_authority.rs index e0849e149e..3449be7dd7 100644 --- a/crates/tee-authority/src/tee_authority.rs +++ b/crates/tee-authority/src/tee_authority.rs @@ -233,8 +233,8 @@ const PCCS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); /// reference and Phala both refresh ~daily), so legitimate operators /// 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. +/// expiry (`DEFAULT_EXPIRATION_DURATION_SECONDS`); the two windows serve +/// different purposes and are not required to match. /// /// Applies uniformly to the three periodically re-signed pieces of /// collateral that share Intel's 30-day window: `tcb_info.issueDate`, From a0c21c0d9926530ad9fade8e336fae87939a7cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 16 Jun 2026 16:54:18 +0200 Subject: [PATCH 03/18] refactor(mpc-attestation): debug_assert on verify_mock_only type misuse verify_mock_only is only valid for Mock attestations; a Dstack input is caller-side misuse, not a verification failure. Add a debug_assert so it fails loudly in debug/test builds, keeping the release error path unchanged. --- crates/mpc-attestation/src/attestation.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 4966e7be8e..ccc4807952 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -224,9 +224,18 @@ impl Attestation { accepted_measurements, current_timestamp_seconds, ), - Self::Dstack(_) => Err(VerificationError::Custom( - "verify_mock_only called on a Dstack attestation".to_string(), - )), + Self::Dstack(_) => { + // Caller-side misuse, not a verification failure: a `Dstack` + // attestation has no mock-only path. Fail loudly in debug/test + // builds; in release, surface it as an error rather than panic. + debug_assert!( + false, + "verify_mock_only called on a Dstack attestation; use verify_with_report" + ); + Err(VerificationError::Custom( + "verify_mock_only called on a Dstack attestation".to_string(), + )) + } } } From ad371d3d0a35528514253c069d442dc5f4c3d894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 17 Jun 2026 13:45:49 +0200 Subject: [PATCH 04/18] refactor: share dcap_qvl conversions and per-variant attestation verify Extract the duplicated dcap_qvl <-> tee-verifier-interface conversions (previously a deliberate sibling copy in attestation/src/dcap_conversions.rs and tee-verifier/src/conversions.rs) into a new tee-verifier-conversions crate. Both the on-chain verifier contract and the off-chain attestation crate now re-export from it, so the Borsh-layout pin tests live in one place. Replace Attestation::verify_mock_only (which carried a debug_assert! for the impossible Dstack arm) with per-variant verification: MockAttestation::verify and a DstackVerify extension trait. Attestation::verify_with_report and verify_locally dispatch to these, so the invalid "Dstack on the mock path" case no longer exists in the type system. --- Cargo.lock | 13 +- Cargo.toml | 2 + crates/attestation-cli/src/verify.rs | 3 +- crates/attestation/Cargo.toml | 16 +- crates/attestation/src/attestation.rs | 31 +- crates/attestation/src/collateral.rs | 30 +- crates/attestation/src/dcap_conversions.rs | 330 +----------------- crates/attestation/src/quote.rs | 2 +- crates/attestation/tests/collateral.rs | 4 +- crates/mpc-attestation/Cargo.toml | 8 +- crates/mpc-attestation/src/attestation.rs | 181 +++++----- crates/tee-verifier-conversions/Cargo.toml | 21 ++ .../src/lib.rs} | 71 +++- crates/tee-verifier-interface/Cargo.toml | 5 +- crates/tee-verifier-interface/src/lib.rs | 11 +- crates/tee-verifier/Cargo.toml | 2 +- crates/tee-verifier/src/lib.rs | 3 +- 17 files changed, 219 insertions(+), 514 deletions(-) create mode 100644 crates/tee-verifier-conversions/Cargo.toml rename crates/{tee-verifier/src/conversions.rs => tee-verifier-conversions/src/lib.rs} (82%) diff --git a/Cargo.lock b/Cargo.lock index 4a8ca52b4b..a1c511fa42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "serde_json", "serde_with", "sha2 0.10.9", + "tee-verifier-conversions", "tee-verifier-interface", "test-utils", "thiserror 2.0.18", @@ -11098,12 +11099,22 @@ dependencies = [ "getrandom 0.2.17", "hex", "near-sdk", - "rstest", "tee-verifier", + "tee-verifier-conversions", "tee-verifier-interface", "test-utils", ] +[[package]] +name = "tee-verifier-conversions" +version = "3.12.0" +dependencies = [ + "borsh", + "dcap-qvl", + "rstest", + "tee-verifier-interface", +] + [[package]] name = "tee-verifier-interface" version = "3.12.0" diff --git a/Cargo.toml b/Cargo.toml index 1ed36c1c03..4d05a5d696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/tee-context", "crates/tee-launcher", "crates/tee-verifier", + "crates/tee-verifier-conversions", "crates/tee-verifier-interface", "crates/test-migration-contract", "crates/test-parallel-contract", @@ -67,6 +68,7 @@ near-mpc-sdk = { path = "crates/near-mpc-sdk", version = "0.0.1" } near-mpc-signature-verifier = { path = "crates/near-mpc-signature-verifier", version = "0.0.1" } node-types = { path = "crates/node-types" } tee-authority = { path = "crates/tee-authority" } +tee-verifier-conversions = { path = "crates/tee-verifier-conversions" } tee-verifier-interface = { path = "crates/tee-verifier-interface" } test-port-allocator = { path = "crates/test-port-allocator" } test-utils = { path = "crates/test-utils" } diff --git a/crates/attestation-cli/src/verify.rs b/crates/attestation-cli/src/verify.rs index 574d244f2c..5491193f0d 100644 --- a/crates/attestation-cli/src/verify.rs +++ b/crates/attestation-cli/src/verify.rs @@ -70,8 +70,7 @@ pub fn verify_at_timestamp( VerificationError::Custom(format!("failed to load expected measurements: {e}")) })?; - // 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. + // Single verify call — same verification logic as the contract and node let AcceptedAttestation { attestation: verified_attestation, advisory_ids, diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index b1c7899831..7ce43305d7 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -10,9 +10,8 @@ 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"] +# do not enable this. +local-verify = ["dep:dcap-qvl", "dep:tee-verifier-conversions"] [dependencies] borsh = { workspace = true } @@ -24,10 +23,7 @@ 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-conversions = { workspace = true, optional = true } tee-verifier-interface = { workspace = true, features = ["serde"] } thiserror = { workspace = true } @@ -35,7 +31,11 @@ thiserror = { workspace = true } 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"] } +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 20a45bd553..6d14c70a49 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -34,11 +34,6 @@ 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, @@ -133,16 +128,6 @@ impl fmt::Debug for DstackAttestation { impl DstackAttestation { /// Runs the post-DCAP checks against an already-verified report. - /// - /// 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, @@ -170,14 +155,7 @@ 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. `mpc-contract` also calls this today (it enables - /// `local-verify`); a planned follow-up moves the DCAP step into a separate - /// verifier contract, after which the contract will call - /// [`verify_with_report`](Self::verify_with_report) directly instead. + /// post-DCAP checks via [`Self::verify_with_report`]. #[cfg(feature = "local-verify")] pub fn verify_locally( &self, @@ -192,15 +170,8 @@ impl DstackAttestation { /// 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 { - // Borrow the quote bytes directly (`QuoteBytes` is a `pub Vec`); the - // collateral clone is unavoidable since `QuoteCollateralV3` owns its - // fields and `into_dcap_type` consumes `self`. let collateral = self.collateral.clone().into_dcap_type(); Ok( dcap_qvl::verify::verify(&self.quote.0, &collateral, timestamp_seconds) diff --git a/crates/attestation/src/collateral.rs b/crates/attestation/src/collateral.rs index 77da03a35c..0a39b97ef5 100644 --- a/crates/attestation/src/collateral.rs +++ b/crates/attestation/src/collateral.rs @@ -1,9 +1,12 @@ //! 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. +//! `Collateral` is re-exported from `tee-verifier-interface`, not redefined, +//! so it has a single canonical definition. +//! +//! The `test-utils` JSON parser below lives here, not in the wire crate: +//! `tee-verifier-interface` is Borsh-only on the cross-contract call, so +//! adding `serde_json` + `hex` there would bloat every consumer's WASM. The +//! only place collateral exists as JSON is off-chain test fixtures. pub use tee_verifier_interface::Collateral; #[cfg(feature = "test-utils")] @@ -12,22 +15,11 @@ pub use parse::{CollateralError, collateral_from_json, collateral_from_str}; #[cfg(feature = "test-utils")] mod parse { use super::Collateral; - use alloc::string::String; - use alloc::vec::Vec; + use alloc::{string::String, 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 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) @@ -58,12 +50,6 @@ mod parse { }) } - /// Parses a JSON string into a [`Collateral`]. - /// - /// # Errors - /// - /// 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)?; diff --git a/crates/attestation/src/dcap_conversions.rs b/crates/attestation/src/dcap_conversions.rs index dcbec66f67..03d52807e0 100644 --- a/crates/attestation/src/dcap_conversions.rs +++ b/crates/attestation/src/dcap_conversions.rs @@ -1,328 +1,10 @@ //! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in -//! `tee-verifier-interface`, for the off-chain `verify_locally` path only. +//! `tee-verifier-interface`, for the off-chain `verify_locally` path. //! -//! 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. +//! Re-exported from `tee-verifier-conversions` so the on-chain `tee-verifier` +//! contract and this off-chain crate share a single definition (and a single +//! Borsh-layout pin test suite) instead of duplicating the mappings. -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, +pub use tee_verifier_conversions::{ + IntoDcapType, IntoInterfaceType, collateral_from_dcap, collateral_into_dcap, }; - -/// 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/quote.rs b/crates/attestation/src/quote.rs index 9a5a9c13db..585148dd91 100644 --- a/crates/attestation/src/quote.rs +++ b/crates/attestation/src/quote.rs @@ -2,5 +2,5 @@ //! //! 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. +//! every consumer. pub use tee_verifier_interface::QuoteBytes; diff --git a/crates/attestation/tests/collateral.rs b/crates/attestation/tests/collateral.rs index 9d7aa3aed0..466ddd68f6 100644 --- a/crates/attestation/tests/collateral.rs +++ b/crates/attestation/tests/collateral.rs @@ -60,9 +60,9 @@ fn test_hex_signature_lengths() { let json_value = collateral(); let collateral = collateral_from_json(json_value).unwrap(); - // The signatures are hex-decoded into raw bytes: a 64-byte ECDSA signature - // is 128 hex chars in the JSON. + // TCB info signature should be 64 hex chars (32 bytes) 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); } diff --git a/crates/mpc-attestation/Cargo.toml b/crates/mpc-attestation/Cargo.toml index d6460c2185..9312477d17 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -5,7 +5,11 @@ license = { workspace = true } edition = { workspace = true } [features] -abi = ["borsh/unstable__schema", "mpc-primitives/abi", "attestation/borsh-schema"] +abi = [ + "borsh/unstable__schema", + "mpc-primitives/abi", + "attestation/borsh-schema", +] dstack-conversions = ["attestation/dstack-conversions"] test-utils = ["attestation/test-utils"] # Enables `Attestation::verify_locally` (full local DCAP + post-DCAP @@ -30,8 +34,6 @@ 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`). diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index ccc4807952..6a26bebc42 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -74,6 +74,29 @@ pub enum MockAttestation { }, } +impl MockAttestation { + pub fn verify( + &self, + current_timestamp_seconds: u64, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + ) -> Result { + let () = verify_mock_attestation( + self, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + current_timestamp_seconds, + )?; + + Ok(AcceptedAttestation { + attestation: VerifiedAttestation::Mock(self.clone()), + advisory_ids: Vec::new(), + }) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), @@ -145,21 +168,28 @@ pub fn default_measurements() -> &'static [ExpectedMeasurements] { &MEASUREMENTS } -impl 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_with_report( +/// Verification for a [`DstackAttestation`] at the `mpc-attestation` layer. +/// +/// `DstackAttestation` is defined in the lower `attestation` crate, which knows +/// nothing of `mpc-primitives` hashes, so the MPC image / launcher compose checks +/// (and the resulting [`AcceptedAttestation`]) live here as an extension trait +/// rather than an inherent method. Mirrors [`MockAttestation::verify`]. +pub trait DstackVerify { + /// Runs the MPC-hash allowlist checks and the post-DCAP checks against an + /// already-DCAP-verified `report`, returning the [`AcceptedAttestation`]. + fn verify( + &self, + report: &VerifiedReport, + expected_report_data: ReportData, + current_timestamp_seconds: u64, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + ) -> Result; +} + +impl DstackVerify for DstackAttestation { + fn verify( &self, report: &VerifiedReport, expected_report_data: ReportData, @@ -168,74 +198,59 @@ impl Attestation { 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, - )?; + let (mpc_image_hash, launcher_compose_hash) = verify_dstack_mpc_hashes( + self, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + )?; - let AcceptedDstackAttestation { - measurements, - advisory_ids, - } = dstack_attestation.verify_with_report( - report, - expected_report_data, - accepted_measurements, - )?; + let AcceptedDstackAttestation { + measurements, + advisory_ids, + } = self.verify_with_report(report, expected_report_data, accepted_measurements)?; - 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, - ), - } + Ok(accepted_dstack_attestation( + mpc_image_hash, + launcher_compose_hash, + measurements, + advisory_ids, + current_timestamp_seconds, + )) } +} - /// 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`. +impl Attestation { + /// Verifies the attestation given an already-DCAP-verified report. /// - /// Returns an error for `Dstack` attestations: those require real DCAP - /// verification, which is not available on this path. - pub fn verify_mock_only( + /// Dispatches to the per-variant verification: [`DstackVerify::verify`] + /// (which consumes `report`) or [`MockAttestation::verify`] (which has no + /// quote and ignores `report`). A caller that already holds a single variant + /// can call its `verify` directly — in particular the contract can verify a + /// `Mock` synchronously via [`MockAttestation::verify`] without a report. + pub fn verify_with_report( &self, + report: &VerifiedReport, + expected_report_data: ReportData, current_timestamp_seconds: u64, allowed_mpc_docker_image_hashes: &[NodeImageHash], allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { match self { - Self::Mock(mock_attestation) => verify_mock( - mock_attestation, + Self::Dstack(dstack_attestation) => dstack_attestation.verify( + report, + expected_report_data, + current_timestamp_seconds, allowed_mpc_docker_image_hashes, allowed_launcher_docker_compose_hashes, accepted_measurements, + ), + Self::Mock(mock_attestation) => mock_attestation.verify( current_timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, ), - Self::Dstack(_) => { - // Caller-side misuse, not a verification failure: a `Dstack` - // attestation has no mock-only path. Fail loudly in debug/test - // builds; in release, surface it as an error rather than panic. - debug_assert!( - false, - "verify_mock_only called on a Dstack attestation; use verify_with_report" - ); - Err(VerificationError::Custom( - "verify_mock_only called on a Dstack attestation".to_string(), - )) - } } } @@ -282,20 +297,20 @@ impl Attestation { current_timestamp_seconds, )) } - Self::Mock(mock_attestation) => verify_mock( - mock_attestation, + Self::Mock(mock_attestation) => mock_attestation.verify( + current_timestamp_seconds, 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. +/// Derives the MPC image hash (from the [`MPC_IMAGE_HASH_EVENT`] TCB-info event) +/// and launcher compose hash (SHA-256 of the app-compose `docker_compose_file`), +/// checks them against `allowed_mpc_docker_image_hashes` and +/// `allowed_launcher_docker_compose_hashes` respectively, and returns the pair. fn verify_dstack_mpc_hashes( dstack_attestation: &DstackAttestation, allowed_mpc_docker_image_hashes: &[NodeImageHash], @@ -361,28 +376,6 @@ fn accepted_dstack_attestation( } } -/// 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/tee-verifier-conversions/Cargo.toml b/crates/tee-verifier-conversions/Cargo.toml new file mode 100644 index 0000000000..69e8c7648c --- /dev/null +++ b/crates/tee-verifier-conversions/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tee-verifier-conversions" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dependencies] +# Logical feature floor is `dcap-qvl/borsh` only: this crate maps types and +# never calls `verify()`, so it needs neither `std`/`ring`/`x509` nor the JSON +# features. We inherit the workspace pin because Cargo feature-unifies +# `dcap-qvl` across the build graph anyway, so a narrower list here would +# change nothing and only risk drift. +dcap-qvl = { workspace = true } +tee-verifier-interface = { workspace = true } + +[dev-dependencies] +borsh = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tee-verifier/src/conversions.rs b/crates/tee-verifier-conversions/src/lib.rs similarity index 82% rename from crates/tee-verifier/src/conversions.rs rename to crates/tee-verifier-conversions/src/lib.rs index ef1377567f..d0c177a902 100644 --- a/crates/tee-verifier/src/conversions.rs +++ b/crates/tee-verifier-conversions/src/lib.rs @@ -1,10 +1,22 @@ //! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in -//! `tee-verifier-interface`. They live here, not in the interface crate, so -//! that crate stays `no_std` and free of `dcap-qvl`. +//! `tee-verifier-interface`. +//! +//! Shared by the on-chain `tee-verifier` contract (which feeds `dcap_qvl::verify` +//! and returns the interface `VerifiedReport`) and the off-chain `attestation` +//! crate's `verify_locally` path. The conversion code's only dependency floor +//! is `dcap-qvl` + `tee-verifier-interface` + `borsh`, which both consumers +//! already carry, so it lives in this minimal crate rather than being duplicated +//! or pulled through `attestation` (whose `serde`/`serde_json`/`sha2`/ +//! `dstack-sdk-types` closure is unrelated to these mappings). //! //! Mapped with the local [`IntoDcapType`] / [`IntoInterfaceType`] traits. We //! can not use [`From`] and [`Into`] due to the [*orphan rule*](https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules). +#![no_std] + +extern crate alloc; + +use alloc::vec::Vec; use dcap_qvl::{quote as dq_quote, tcb_info as dq_tcb, verify as dq_verify}; use tee_verifier_interface::{ Collateral, EnclaveReport, QuoteBytes, Report, TDReport10, TDReport15, TcbStatus, @@ -12,12 +24,12 @@ use tee_verifier_interface::{ }; /// Converts an interface type into its `dcap_qvl` counterpart `T`. -pub(crate) trait IntoDcapType { +pub trait IntoDcapType { fn into_dcap_type(self) -> T; } /// Converts a `dcap_qvl` type into its `tee-verifier-interface` counterpart `T`. -pub(crate) trait IntoInterfaceType { +pub trait IntoInterfaceType { fn into_interface_type(self) -> T; } @@ -38,6 +50,36 @@ impl IntoDcapType for Collateral { } } +impl IntoInterfaceType for dcap_qvl::QuoteCollateralV3 { + fn into_interface_type(self) -> Collateral { + Collateral { + pck_crl_issuer_chain: self.pck_crl_issuer_chain, + root_ca_crl: self.root_ca_crl, + pck_crl: self.pck_crl, + tcb_info_issuer_chain: self.tcb_info_issuer_chain, + tcb_info: self.tcb_info, + tcb_info_signature: self.tcb_info_signature, + qe_identity_issuer_chain: self.qe_identity_issuer_chain, + qe_identity: self.qe_identity, + qe_identity_signature: self.qe_identity_signature, + pck_certificate_chain: self.pck_certificate_chain, + } + } +} + +/// 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 @@ -159,6 +201,7 @@ impl IntoInterfaceType for dq_tcb::TcbStatusWithAdvisory #[expect(non_snake_case)] mod tests { use super::*; + use alloc::{string::ToString, vec}; use rstest::rstest; /// Asserts the two values encode to identical Borsh bytes. @@ -173,16 +216,16 @@ mod tests { fn sample_collateral() -> Collateral { Collateral { - pck_crl_issuer_chain: "issuer-chain".into(), + pck_crl_issuer_chain: "issuer-chain".to_string(), root_ca_crl: vec![1, 2, 3], pck_crl: vec![4, 5, 6], - tcb_info_issuer_chain: "tcb-issuer".into(), - tcb_info: "tcb-info-json".into(), + tcb_info_issuer_chain: "tcb-issuer".to_string(), + tcb_info: "tcb-info-json".to_string(), tcb_info_signature: vec![7, 8], - qe_identity_issuer_chain: "qe-issuer".into(), - qe_identity: "qe-identity-json".into(), + qe_identity_issuer_chain: "qe-issuer".to_string(), + qe_identity: "qe-identity-json".to_string(), qe_identity_signature: vec![9, 10], - pck_certificate_chain: Some("pck-chain".into()), + pck_certificate_chain: Some("pck-chain".to_string()), } } @@ -233,8 +276,8 @@ mod tests { fn dcap_verified_report(report: dq_quote::Report) -> dq_verify::VerifiedReport { dq_verify::VerifiedReport { - status: "UpToDate".into(), - advisory_ids: vec!["INTEL-SA-00001".into()], + status: "UpToDate".to_string(), + advisory_ids: vec!["INTEL-SA-00001".to_string()], report, ppid: vec![0xAB; 16], qe_status: dq_tcb::TcbStatusWithAdvisory { @@ -243,7 +286,7 @@ mod tests { }, platform_status: dq_tcb::TcbStatusWithAdvisory { status: dq_tcb::TcbStatus::ConfigurationNeeded, - advisory_ids: vec!["INTEL-SA-00002".into()], + advisory_ids: vec!["INTEL-SA-00002".to_string()], }, } } @@ -321,7 +364,7 @@ mod tests { fn tcb_status_with_advisory__should_match_dcap_borsh_layout() { let dcap = dq_tcb::TcbStatusWithAdvisory { status: dq_tcb::TcbStatus::ConfigurationNeeded, - advisory_ids: vec!["INTEL-SA-00003".into()], + advisory_ids: vec!["INTEL-SA-00003".to_string()], }; let interface: TcbStatusWithAdvisory = dcap.clone().into_interface_type(); assert_same_borsh_bytes(&interface, &dcap); diff --git a/crates/tee-verifier-interface/Cargo.toml b/crates/tee-verifier-interface/Cargo.toml index 55003925a5..8b486d4f7b 100644 --- a/crates/tee-verifier-interface/Cargo.toml +++ b/crates/tee-verifier-interface/Cargo.toml @@ -7,9 +7,8 @@ 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. +# (`QuoteBytes`, `Collateral`) for off-chain callers that embed them in serde +# structs. The Borsh cross-contract ABI never enables it. serde = ["dep:serde"] [dependencies] diff --git a/crates/tee-verifier-interface/src/lib.rs b/crates/tee-verifier-interface/src/lib.rs index 999601fd20..8655a7e80b 100644 --- a/crates/tee-verifier-interface/src/lib.rs +++ b/crates/tee-verifier-interface/src/lib.rs @@ -43,12 +43,9 @@ 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 +// The off-by-default `serde` feature is for off-chain callers that embed the +// verifier *input* types (`QuoteBytes`, `Collateral`) in serde structs. The +// Borsh cross-contract ABI never enables it; the report/output types stay // Borsh-only. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct QuoteBytes(pub Vec); @@ -60,7 +57,7 @@ pub struct QuoteBytes(pub Vec); #[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. +// 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, diff --git a/crates/tee-verifier/Cargo.toml b/crates/tee-verifier/Cargo.toml index ef6cef3da1..22189baee9 100644 --- a/crates/tee-verifier/Cargo.toml +++ b/crates/tee-verifier/Cargo.toml @@ -35,6 +35,7 @@ test-utils = ["near-sdk/unit-testing"] borsh = { workspace = true } dcap-qvl = { workspace = true } near-sdk = { workspace = true } +tee-verifier-conversions = { workspace = true } tee-verifier-interface = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -42,7 +43,6 @@ getrandom = { workspace = true, features = ["custom"] } [dev-dependencies] hex = { workspace = true } -rstest = { workspace = true } tee-verifier = { path = ".", features = ["test-utils"] } test-utils = { workspace = true } diff --git a/crates/tee-verifier/src/lib.rs b/crates/tee-verifier/src/lib.rs index b44d711495..7689c527cc 100644 --- a/crates/tee-verifier/src/lib.rs +++ b/crates/tee-verifier/src/lib.rs @@ -11,8 +11,7 @@ use near_sdk::{env, near}; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; -mod conversions; -use conversions::{IntoDcapType as _, IntoInterfaceType as _}; +use tee_verifier_conversions::{IntoDcapType as _, IntoInterfaceType as _}; // `dcap-qvl`'s `contract` feature pulls in `getrandom` but doesn't enable // any backend. On `wasm32-unknown-unknown` we register a custom impl that From 07e7b2230026df0499abef05afb1ce7e8fa974d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Wed, 17 Jun 2026 18:31:41 +0200 Subject: [PATCH 05/18] refactor(attestation): polish per-variant verify API and docs - Inline verify_mock_attestation into MockAttestation::verify; route re_verify's Mock arm through it. - Add AcceptedAttestation::dstack / ::mock constructors, replacing the free fn accepted_dstack_attestation and the inline struct literals. - Rename DstackAttestation::dcap_report to verify_dcap_quote (it runs DCAP verification and returns the report, rather than being a getter). - TODO(#3264) at the contract's verify_locally call site and on the local-verify feature flags / docs, marking the transitional in-WASM DCAP path the verifier-contract follow-up removes. - Tighten doc comments across the attestation/tee-verifier crates; drop function names and external party names from config-level comments so they don't go stale. --- crates/attestation/Cargo.toml | 8 +- crates/attestation/src/attestation.rs | 12 +- crates/contract/src/tee/tee_state.rs | 2 + crates/mpc-attestation/Cargo.toml | 17 +- crates/mpc-attestation/src/attestation.rs | 230 ++++++++---------- .../tests/test_attestation_verification.rs | 20 +- .../convert_to_contract_dto.rs | 2 +- crates/tee-verifier-conversions/Cargo.toml | 5 - crates/tee-verifier-conversions/src/lib.rs | 19 +- crates/tee-verifier-interface/src/lib.rs | 19 +- crates/test-utils/src/attestation.rs | 2 - 11 files changed, 149 insertions(+), 187 deletions(-) diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index 7ce43305d7..ee6931d59f 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -8,9 +8,9 @@ edition = { workspace = true } 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. +# Pulls in `dcap-qvl` for full local DCAP + post-DCAP verification. Meant for +# off-chain callers; `mpc-contract` enables it today. +# TODO(#3264): contract drops this once DCAP moves to the verifier contract. local-verify = ["dep:dcap-qvl", "dep:tee-verifier-conversions"] [dependencies] @@ -29,8 +29,6 @@ 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", diff --git a/crates/attestation/src/attestation.rs b/crates/attestation/src/attestation.rs index 6d14c70a49..457cc14ab8 100644 --- a/crates/attestation/src/attestation.rs +++ b/crates/attestation/src/attestation.rs @@ -41,9 +41,10 @@ pub struct DstackAttestation { pub tcb_info: TcbInfo, } -/// Result of a successful [`DstackAttestation::verify_with_report`] call. +/// Result of successfully verifying an attestation. #[derive(Clone, Debug)] pub struct AcceptedDstackAttestation { + /// The accepted measurement set this attestation matched. pub measurements: ExpectedMeasurements, /// Informational advisory IDs (e.g. `INTEL-DOC-10000` post-ESU) surfaced by /// Intel's PCS alongside an `UpToDate` TCB status. They are not a security @@ -163,15 +164,18 @@ impl DstackAttestation { timestamp_seconds: u64, accepted_measurements: &[ExpectedMeasurements], ) -> Result { - let report = self.dcap_report(timestamp_seconds)?; + let report = self.verify_dcap_quote(timestamp_seconds)?; self.verify_with_report(&report, expected_report_data, accepted_measurements) } /// Runs only the DCAP step (`dcap_qvl::verify::verify`) and returns the /// resulting report as the `tee-verifier-interface` mirror — the same value - /// the `tee-verifier` contract returns on-chain. Off-chain only. + /// the `tee-verifier` contract returns on-chain. #[cfg(feature = "local-verify")] - pub fn dcap_report(&self, timestamp_seconds: u64) -> Result { + pub fn verify_dcap_quote( + &self, + timestamp_seconds: u64, + ) -> Result { let collateral = self.collateral.clone().into_dcap_type(); Ok( dcap_qvl::verify::verify(&self.quote.0, &collateral, timestamp_seconds) diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 1c607313d5..59d9f932fb 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -156,6 +156,8 @@ impl TeeState { .into(); let accepted_measurements = self.get_accepted_measurements(); + // TODO(#3264): run DCAP in the verifier contract (Promise + callback) and + // do the post-DCAP checks here, instead of verifying locally in-WASM. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, diff --git a/crates/mpc-attestation/Cargo.toml b/crates/mpc-attestation/Cargo.toml index 9312477d17..b0c32c17d2 100644 --- a/crates/mpc-attestation/Cargo.toml +++ b/crates/mpc-attestation/Cargo.toml @@ -5,17 +5,14 @@ license = { workspace = true } edition = { workspace = true } [features] -abi = [ - "borsh/unstable__schema", - "mpc-primitives/abi", - "attestation/borsh-schema", -] +abi = ["borsh/unstable__schema", "mpc-primitives/abi", "attestation/borsh-schema"] dstack-conversions = ["attestation/dstack-conversions"] test-utils = ["attestation/test-utils"] -# Enables `Attestation::verify_locally` (full local DCAP + post-DCAP -# verification), forwarding to `attestation/local-verify` which pulls in -# `dcap-qvl`. Used off-chain (node, tee-authority, attestation-cli) and, for -# now, by the contract's synchronous attestation path. +# Enables full local DCAP + post-DCAP verification, forwarding to +# `attestation/local-verify` which pulls in `dcap-qvl`. Used off-chain (node, +# tee-authority, attestation-cli) and, for now, by the contract's synchronous +# attestation path. +# TODO(#3264): contract drops this once DCAP moves to the verifier contract. local-verify = ["attestation/local-verify"] [dependencies] @@ -35,8 +32,6 @@ tee-verifier-interface = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } 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 } diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 6a26bebc42..3beaa0dd5c 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -41,9 +41,11 @@ pub enum VerifiedAttestation { Mock(MockAttestation), } -/// Result of a successful [`Attestation::verify_with_report`] call. +/// Result of successfully verifying an attestation. #[derive(Clone, Debug)] pub struct AcceptedAttestation { + /// The validated attestation data extracted during verification, stored for + /// later re-verification against the then-current allowed set. pub attestation: VerifiedAttestation, /// Informational advisory IDs (e.g. `INTEL-DOC-10000` post-ESU) surfaced by /// Intel's PCS alongside an `UpToDate` TCB status. They are not a security @@ -52,6 +54,39 @@ pub struct AcceptedAttestation { pub advisory_ids: Vec, } +impl AcceptedAttestation { + /// Assembles the acceptance for a verified `Dstack` attestation, stamping the + /// expiry. + fn dstack( + mpc_image_hash: NodeImageHash, + launcher_compose_hash: LauncherDockerComposeHash, + measurements: ExpectedMeasurements, + advisory_ids: Vec, + current_timestamp_seconds: u64, + ) -> Self { + // TODO(#1639): extract timestamp from certificate itself + let expiration_timestamp_seconds = + current_timestamp_seconds + DEFAULT_EXPIRATION_DURATION_SECONDS; + Self { + attestation: VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash, + launcher_compose_hash, + expiry_timestamp_seconds: expiration_timestamp_seconds, + measurements, + }), + advisory_ids, + } + } + + /// Assembles the acceptance for a verified `Mock` attestation. + fn mock(mock_attestation: &MockAttestation) -> Self { + Self { + attestation: VerifiedAttestation::Mock(mock_attestation.clone()), + advisory_ids: Vec::new(), + } + } +} + #[expect(clippy::large_enum_variant)] #[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] #[cfg_attr( @@ -82,18 +117,62 @@ impl MockAttestation { allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { - let () = verify_mock_attestation( - self, - allowed_mpc_docker_image_hashes, - allowed_launcher_docker_compose_hashes, - accepted_measurements, - current_timestamp_seconds, - )?; + match self { + MockAttestation::Valid => Ok(()), + MockAttestation::Invalid => Err(VerificationError::InvalidMockAttestation), + MockAttestation::WithConstraints { + mpc_docker_image_hash, + launcher_docker_compose_hash, + expiry_timestamp_seconds, + expected_measurements, + } => { + if let Some(hash) = mpc_docker_image_hash { + if allowed_mpc_docker_image_hashes.is_empty() { + return Err(VerificationError::Custom( + "the allowed mpc image hashes list is empty".to_string(), + )); + } + allowed_mpc_docker_image_hashes.contains(hash).or_err(|| { + VerificationError::Custom(format!( + "MPC image hash {} is not in the allowed hashes list", + hex::encode(hash.as_ref(),) + )) + })?; + }; - Ok(AcceptedAttestation { - attestation: VerifiedAttestation::Mock(self.clone()), - advisory_ids: Vec::new(), - }) + if let Some(hash) = launcher_docker_compose_hash { + if allowed_launcher_docker_compose_hashes.is_empty() { + return Err(VerificationError::Custom( + "the allowed mpc launcher compose hashes list is empty".to_string(), + )); + } + allowed_launcher_docker_compose_hashes + .contains(hash) + .or_err(|| { + VerificationError::Custom(format!( + "launcher compose hash {} is not in the allowed hashes list", + hex::encode(hash.as_ref(),) + )) + })?; + }; + if let Some(expiry_timestamp) = expiry_timestamp_seconds { + (current_timestamp_seconds < *expiry_timestamp).or_err(|| { + VerificationError::ExpiredCertificate { + attestation_time: current_timestamp_seconds, + expiry_time: *expiry_timestamp, + } + })?; + }; + + if let Some(measurements) = expected_measurements { + verify_measurements(measurements, accepted_measurements)?; + } + + Ok(()) + } + }?; + + Ok(AcceptedAttestation::mock(self)) } } @@ -147,13 +226,14 @@ impl VerifiedAttestation { Ok(()) } - Self::Mock(mock_attestation) => verify_mock_attestation( - mock_attestation, - allowed_mpc_docker_image_hashes, - allowed_launcher_docker_compose_hashes, - allowed_measurements, - timestamp_seconds, - ), + Self::Mock(mock_attestation) => mock_attestation + .verify( + timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + allowed_measurements, + ) + .map(|_| ()), } } } @@ -170,13 +250,14 @@ pub fn default_measurements() -> &'static [ExpectedMeasurements] { /// Verification for a [`DstackAttestation`] at the `mpc-attestation` layer. /// -/// `DstackAttestation` is defined in the lower `attestation` crate, which knows +/// [`DstackAttestation`] is defined in the lower `attestation` crate, which knows /// nothing of `mpc-primitives` hashes, so the MPC image / launcher compose checks /// (and the resulting [`AcceptedAttestation`]) live here as an extension trait /// rather than an inherent method. Mirrors [`MockAttestation::verify`]. pub trait DstackVerify { /// Runs the MPC-hash allowlist checks and the post-DCAP checks against an - /// already-DCAP-verified `report`, returning the [`AcceptedAttestation`]. + /// already-DCAP-verified [`VerifiedReport`], returning the + /// [`AcceptedAttestation`]. fn verify( &self, report: &VerifiedReport, @@ -209,7 +290,7 @@ impl DstackVerify for DstackAttestation { advisory_ids, } = self.verify_with_report(report, expected_report_data, accepted_measurements)?; - Ok(accepted_dstack_attestation( + Ok(AcceptedAttestation::dstack( mpc_image_hash, launcher_compose_hash, measurements, @@ -221,12 +302,6 @@ impl DstackVerify for DstackAttestation { impl Attestation { /// Verifies the attestation given an already-DCAP-verified report. - /// - /// Dispatches to the per-variant verification: [`DstackVerify::verify`] - /// (which consumes `report`) or [`MockAttestation::verify`] (which has no - /// quote and ignores `report`). A caller that already holds a single variant - /// can call its `verify` directly — in particular the contract can verify a - /// `Mock` synchronously via [`MockAttestation::verify`] without a report. pub fn verify_with_report( &self, report: &VerifiedReport, @@ -255,14 +330,9 @@ impl Attestation { } /// 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`. `mpc-contract` - /// also calls this today (it enables `local-verify`); a planned follow-up - /// moves the DCAP step into a separate verifier contract, after which the - /// contract will call [`verify_with_report`](Self::verify_with_report) - /// directly instead. + /// the post-DCAP checks. Behind the `local-verify` feature, which pulls in + /// `dcap-qvl`. Used by off-chain callers and, today, by `mpc-contract`. + // TODO(#3264): contract drops this once DCAP moves to the verifier contract. #[cfg(feature = "local-verify")] pub fn verify_locally( &self, @@ -289,7 +359,7 @@ impl Attestation { accepted_measurements, )?; - Ok(accepted_dstack_attestation( + Ok(AcceptedAttestation::dstack( mpc_image_hash, launcher_compose_hash, measurements, @@ -353,29 +423,6 @@ fn verify_dstack_mpc_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 MPC node image hash is in allowed list. fn verify_mpc_hash( image_hash: &NodeImageHash, @@ -435,69 +482,6 @@ fn verify_measurements( Ok(()) } -pub(crate) fn verify_mock_attestation( - mock_attestation: &MockAttestation, - allowed_mpc_docker_image_hashes: &[NodeImageHash], - allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], - allowed_measurements: &[ExpectedMeasurements], - timestamp_seconds: u64, -) -> Result<(), VerificationError> { - match mock_attestation { - MockAttestation::Valid => Ok(()), - MockAttestation::Invalid => Err(VerificationError::InvalidMockAttestation), - MockAttestation::WithConstraints { - mpc_docker_image_hash, - launcher_docker_compose_hash, - expiry_timestamp_seconds, - expected_measurements, - } => { - if let Some(hash) = mpc_docker_image_hash { - if allowed_mpc_docker_image_hashes.is_empty() { - return Err(VerificationError::Custom( - "the allowed mpc image hashes list is empty".to_string(), - )); - } - allowed_mpc_docker_image_hashes.contains(hash).or_err(|| { - VerificationError::Custom(format!( - "MPC image hash {} is not in the allowed hashes list", - hex::encode(hash.as_ref(),) - )) - })?; - }; - - if let Some(hash) = launcher_docker_compose_hash { - if allowed_launcher_docker_compose_hashes.is_empty() { - return Err(VerificationError::Custom( - "the allowed mpc launcher compose hashes list is empty".to_string(), - )); - } - allowed_launcher_docker_compose_hashes - .contains(hash) - .or_err(|| { - VerificationError::Custom(format!( - "launcher compose hash {} is not in the allowed hashes list", - hex::encode(hash.as_ref(),) - )) - })?; - }; - if let Some(expiry_timestamp) = expiry_timestamp_seconds { - (timestamp_seconds < *expiry_timestamp).or_err(|| { - VerificationError::ExpiredCertificate { - attestation_time: timestamp_seconds, - expiry_time: *expiry_timestamp, - } - })?; - }; - - if let Some(measurements) = expected_measurements { - verify_measurements(measurements, allowed_measurements)?; - } - - Ok(()) - } - } -} - #[cfg(test)] mod tests { use alloc::vec; diff --git a/crates/mpc-attestation/tests/test_attestation_verification.rs b/crates/mpc-attestation/tests/test_attestation_verification.rs index 9176808776..337619d288 100644 --- a/crates/mpc-attestation/tests/test_attestation_verification.rs +++ b/crates/mpc-attestation/tests/test_attestation_verification.rs @@ -55,6 +55,7 @@ fn invalid_mock_attestation_fails_verification() { #[test] #[expect(non_snake_case)] fn verify_with_report__should_agree_with_verify_locally() { + // Given let attestation = mock_dstack_attestation(); let tls_key = p2p_tls_key(); let account_key = account_key(); @@ -62,7 +63,12 @@ fn verify_with_report__should_agree_with_verify_locally() { let timestamp_s = VALID_ATTESTATION_TIMESTAMP; let allowed_mpc_hashes = [image_digest()]; let allowed_launcher_hashes = [launcher_compose_digest()]; + let measurements = default_measurements(); + let Attestation::Dstack(dstack) = &attestation else { + panic!("fixture is a Dstack attestation"); + }; + // When // Full local verify (DCAP + post-DCAP). let local = attestation .verify_locally( @@ -70,19 +76,14 @@ fn verify_with_report__should_agree_with_verify_locally() { timestamp_s, &allowed_mpc_hashes, &allowed_launcher_hashes, - default_measurements(), + 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"); - + .verify_dcap_quote(timestamp_s) + .expect("dcap quote verification should produce a report"); let with_report = attestation .verify_with_report( &report, @@ -90,10 +91,11 @@ fn verify_with_report__should_agree_with_verify_locally() { timestamp_s, &allowed_mpc_hashes, &allowed_launcher_hashes, - default_measurements(), + measurements, ) .expect("verify_with_report should succeed"); + // Then // `VerifiedAttestation` has no `PartialEq`; compare via its Borsh encoding, // which is the form actually stored on-chain. assert_eq!( 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 319f6f207f..65d99b9717 100644 --- a/crates/node/src/trait_extensions/convert_to_contract_dto.rs +++ b/crates/node/src/trait_extensions/convert_to_contract_dto.rs @@ -87,7 +87,7 @@ impl IntoContractInterfaceType for Collateral { fn into_contract_interface_type(self) -> near_mpc_contract_interface::types::Collateral { // TODO(#3494): drop this conversion once the DTO carries the interface - // `Collateral` directly (L4). + // `Collateral` directly. let Collateral { pck_crl_issuer_chain, root_ca_crl, diff --git a/crates/tee-verifier-conversions/Cargo.toml b/crates/tee-verifier-conversions/Cargo.toml index 69e8c7648c..b112582332 100644 --- a/crates/tee-verifier-conversions/Cargo.toml +++ b/crates/tee-verifier-conversions/Cargo.toml @@ -5,11 +5,6 @@ license = { workspace = true } edition = { workspace = true } [dependencies] -# Logical feature floor is `dcap-qvl/borsh` only: this crate maps types and -# never calls `verify()`, so it needs neither `std`/`ring`/`x509` nor the JSON -# features. We inherit the workspace pin because Cargo feature-unifies -# `dcap-qvl` across the build graph anyway, so a narrower list here would -# change nothing and only risk drift. dcap-qvl = { workspace = true } tee-verifier-interface = { workspace = true } diff --git a/crates/tee-verifier-conversions/src/lib.rs b/crates/tee-verifier-conversions/src/lib.rs index d0c177a902..e0907b972e 100644 --- a/crates/tee-verifier-conversions/src/lib.rs +++ b/crates/tee-verifier-conversions/src/lib.rs @@ -1,13 +1,13 @@ //! Conversions between `dcap_qvl`'s types and the Borsh-mirrored types in //! `tee-verifier-interface`. //! -//! Shared by the on-chain `tee-verifier` contract (which feeds `dcap_qvl::verify` -//! and returns the interface `VerifiedReport`) and the off-chain `attestation` -//! crate's `verify_locally` path. The conversion code's only dependency floor -//! is `dcap-qvl` + `tee-verifier-interface` + `borsh`, which both consumers -//! already carry, so it lives in this minimal crate rather than being duplicated -//! or pulled through `attestation` (whose `serde`/`serde_json`/`sha2`/ -//! `dstack-sdk-types` closure is unrelated to these mappings). +//! Shared by the on-chain `tee-verifier` contract and the off-chain +//! `attestation` crate's `verify_locally` path. +//! +//! These mappings need only `dcap-qvl` + `tee-verifier-interface` + `borsh`, so +//! they live in this minimal crate. Putting them in `attestation` instead would +//! force the contract to pull that crate's unrelated dependencies (`serde`, +//! `sha2`, etc.) into its WASM. //! //! Mapped with the local [`IntoDcapType`] / [`IntoInterfaceType`] traits. We //! can not use [`From`] and [`Into`] due to the [*orphan rule*](https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules). @@ -67,15 +67,10 @@ impl IntoInterfaceType for dcap_qvl::QuoteCollateralV3 { } } -/// 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() } diff --git a/crates/tee-verifier-interface/src/lib.rs b/crates/tee-verifier-interface/src/lib.rs index 8655a7e80b..5dd2c3467a 100644 --- a/crates/tee-verifier-interface/src/lib.rs +++ b/crates/tee-verifier-interface/src/lib.rs @@ -3,21 +3,10 @@ //! Field-for-field mirrors of the `dcap_qvl` input and output types, //! owned here so the Borsh wire layout is independent of upstream. //! -//! This crate is the *only* DTO crate a consumer (`mpc-contract`, future -//! Proximity / Defuse contracts) needs in order to talk to the verifier — -//! without re-linking the `dcap-qvl` / `ring` / `webpki` / `x509-cert` -//! closure into its own WASM. The crate is `no_std` and has no -//! `dcap-qvl` dependency; the `From` conversions live in -//! the `tee-verifier` contract crate, the only crate that depends on -//! both. -//! -//! Borsh-only on purpose. The verifier is reached only over a cross-contract -//! call (Borsh ABI), so there is no JSON wire and serde would just add -//! dependencies. The payload is mostly binary anyway (a multi-KB quote -//! plus collateral), which Borsh sends as raw bytes where JSON would inflate -//! it into integer arrays. Byte fields stay plain `Vec` / arrays rather -//! than serde/hex wrappers, which also keeps the layout a field-for-field -//! Borsh mirror of `dcap_qvl`. +//! It is the only DTO crate a consumer (`mpc-contract` and future external +//! contracts) needs to talk to the verifier, without linking `dcap-qvl` into +//! its own WASM. `no_std`, no `dcap-qvl` dependency; the `dcap_qvl` conversions +//! live in `tee-verifier-conversions`. #![no_std] diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index fe12b0417b..3c4af6c72e 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -66,8 +66,6 @@ pub fn collateral() -> Value { pub fn quote() -> QuoteBytes { 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) From 8a4761cb79b80f0fe7138bfebae48309a01f4610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 18 Jun 2026 14:47:37 +0200 Subject: [PATCH 06/18] refactor(mpc-attestation): extract MockAttestation::verify_constraints Split the mock constraint check out of `verify` into a private `verify_constraints` returning `Result<(), _>`. `verify` calls it and wraps the result into an `AcceptedAttestation`; `re_verify`'s Mock arm calls it directly, so the periodic on-chain re-verification no longer builds (and discards) an `AcceptedAttestation`. --- crates/mpc-attestation/src/attestation.rs | 36 ++++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 3beaa0dd5c..00fc6c4a6f 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -117,6 +117,24 @@ impl MockAttestation { allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], accepted_measurements: &[ExpectedMeasurements], ) -> Result { + self.verify_constraints( + current_timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + accepted_measurements, + )?; + Ok(AcceptedAttestation::mock(self)) + } + + /// Checks the mock's constraints, returning only pass/fail. Lets the + /// re-verification path validate without building an [`AcceptedAttestation`]. + fn verify_constraints( + &self, + current_timestamp_seconds: u64, + allowed_mpc_docker_image_hashes: &[NodeImageHash], + allowed_launcher_docker_compose_hashes: &[LauncherDockerComposeHash], + accepted_measurements: &[ExpectedMeasurements], + ) -> Result<(), VerificationError> { match self { MockAttestation::Valid => Ok(()), MockAttestation::Invalid => Err(VerificationError::InvalidMockAttestation), @@ -170,9 +188,7 @@ impl MockAttestation { Ok(()) } - }?; - - Ok(AcceptedAttestation::mock(self)) + } } } @@ -226,14 +242,12 @@ impl VerifiedAttestation { Ok(()) } - Self::Mock(mock_attestation) => mock_attestation - .verify( - timestamp_seconds, - allowed_mpc_docker_image_hashes, - allowed_launcher_docker_compose_hashes, - allowed_measurements, - ) - .map(|_| ()), + Self::Mock(mock_attestation) => mock_attestation.verify_constraints( + timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, + allowed_measurements, + ), } } } From fc1129af9481179ea797c8cf94b8ca6f5b407451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 18 Jun 2026 15:03:13 +0200 Subject: [PATCH 07/18] refactor(mpc-attestation): route verify_locally's Dstack arm through DstackVerify::verify The Dstack arm of `Attestation::verify_locally` re-inlined `verify_dstack_mpc_hashes` + the `AcceptedAttestation::dstack` assembly that `DstackVerify::verify` already does. Route it through `verify_dcap_quote` + `DstackVerify::verify` instead, removing the duplication and running DCAP before the post-DCAP checks (matching the async verifier flow). Drop verify_with_report__should_agree_with_verify_locally: after the dedup, verify_locally is verify_dcap_quote plus the same path verify_with_report dispatches to, so the agreement holds by construction. --- crates/mpc-attestation/src/attestation.rs | 25 ++------ .../tests/test_attestation_verification.rs | 57 ------------------- 2 files changed, 6 insertions(+), 76 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 00fc6c4a6f..1f6e6a517d 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -358,28 +358,15 @@ impl Attestation { ) -> 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, - )?; - - let AcceptedDstackAttestation { - measurements, - advisory_ids, - } = dstack_attestation.verify_locally( + let report = dstack_attestation.verify_dcap_quote(current_timestamp_seconds)?; + dstack_attestation.verify( + &report, expected_report_data, current_timestamp_seconds, + allowed_mpc_docker_image_hashes, + allowed_launcher_docker_compose_hashes, accepted_measurements, - )?; - - Ok(AcceptedAttestation::dstack( - mpc_image_hash, - launcher_compose_hash, - measurements, - advisory_ids, - current_timestamp_seconds, - )) + ) } Self::Mock(mock_attestation) => mock_attestation.verify( current_timestamp_seconds, diff --git a/crates/mpc-attestation/tests/test_attestation_verification.rs b/crates/mpc-attestation/tests/test_attestation_verification.rs index 337619d288..36d20c4ff3 100644 --- a/crates/mpc-attestation/tests/test_attestation_verification.rs +++ b/crates/mpc-attestation/tests/test_attestation_verification.rs @@ -48,63 +48,6 @@ fn invalid_mock_attestation_fails_verification() { ); } -/// `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] -#[expect(non_snake_case)] -fn verify_with_report__should_agree_with_verify_locally() { - // Given - 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()]; - let measurements = default_measurements(); - let Attestation::Dstack(dstack) = &attestation else { - panic!("fixture is a Dstack attestation"); - }; - - // When - // Full local verify (DCAP + post-DCAP). - let local = attestation - .verify_locally( - report_data.clone().into(), - timestamp_s, - &allowed_mpc_hashes, - &allowed_launcher_hashes, - 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 report = dstack - .verify_dcap_quote(timestamp_s) - .expect("dcap quote verification should produce a report"); - let with_report = attestation - .verify_with_report( - &report, - report_data.into(), - timestamp_s, - &allowed_mpc_hashes, - &allowed_launcher_hashes, - measurements, - ) - .expect("verify_with_report should succeed"); - - // Then - // `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 From 036c603b0f5c54c58512a03df7fe1160310603cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 18 Jun 2026 17:51:12 +0200 Subject: [PATCH 08/18] feat(contract): TEE verifier-account voting + lower attestation expiry Adds the on-chain governance for choosing the trusted `tee-verifier` account, ahead of the async verification flow that will use it. Verification stays synchronous (`verify_locally`), so this is behavior-preserving. - New `tee::verifier_votes` module: `VerifierChangeProposal` (candidate account + audited code hash) and `TeeVerifierVotes` on the existing `Votes` primitive, mirroring the foreign-chain provider vote. - `MpcContract` gains `tee_verifier_account_id` (starts at an unset placeholder) and `tee_verifier_votes`. `vote_tee_verifier_change` / `withdraw_tee_verifier_vote` let participants vote one in by threshold; the placeholder is rejected as a candidate so a quorum can't roll the verifier back to unconfigured. Stale votes are swept post-resharing in `clean_foreign_chain_data`. - Migration starts deployed contracts from the placeholder; fresh deploys may set `tee_verifier_account_id` via `InitConfig`. - Lower `DEFAULT_EXPIRATION_DURATION_SECONDS` 7d -> 1d to bound how long a wrongly-accepted attestation stays trusted after a verifier rotation. Regenerates the ABI and borsh-schema snapshots. --- .../tests/test_verification.rs | 4 +- crates/contract/src/errors.rs | 4 + crates/contract/src/lib.rs | 121 ++++++++ ...contract_borsh_schema_has_not_changed.snap | 32 ++ crates/contract/src/storage_keys.rs | 2 + crates/contract/src/tee.rs | 1 + crates/contract/src/tee/verifier_votes.rs | 277 ++++++++++++++++++ crates/contract/src/v3_12_0_state.rs | 7 +- .../tests/sandbox/contract_configuration.rs | 3 + .../snapshots/abi__abi_has_not_changed.snap | 54 ++++ crates/mpc-attestation/src/attestation.rs | 8 +- .../src/method_names.rs | 2 + .../src/types/config.rs | 7 + 13 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 crates/contract/src/tee/verifier_votes.rs 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/src/errors.rs b/crates/contract/src/errors.rs index 4a58fd9a67..d5c059cef9 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -28,6 +28,10 @@ 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( + "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 a9d82dbb31..b26c132aaf 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -49,6 +49,7 @@ use crate::{ state::ContractNotInitialized, storage_keys::StorageKey, tee::tee_state::{TeeQuoteStatus, TeeState}, + tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, }; use config::Config; @@ -140,6 +141,22 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } +/// Placeholder `tee_verifier_account_id` before participants have voted in a +/// real verifier. Never deployed and never called; `vote_tee_verifier_change` +/// refuses to vote it in, so the verifier can only move away from it. +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") + }) +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -164,6 +181,12 @@ pub struct MpcContract { // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chains: Lazy, + /// Account whose `verify_quote` the contract trusts for DCAP verification. + /// Starts at the [`UNSET_TEE_VERIFIER_ACCOUNT`] placeholder until + /// participants vote one in. Not yet used to dispatch verification (the + /// async flow lands in a follow-up); stored and voted on here. + tee_verifier_account_id: AccountId, + tee_verifier_votes: TeeVerifierVotes, } #[near(serializers=[borsh])] @@ -1661,6 +1684,70 @@ impl MpcContract { Ok(applied) } + /// Vote for `candidate_account_id` to become the trusted `tee-verifier` + /// account. `expected_code_hash` commits the voter to the code they audited. + /// When the proposal crosses the signing threshold, `tee_verifier_account_id` + /// is updated and all pending verifier-change votes are cleared. + #[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(); + + // Reject the placeholder up front so a quorum can never roll the verifier + // back to the unconfigured state. + if candidate_account_id == initial_tee_verifier_account_id(None) { + return Err(TeeError::VerifierCandidateIsPlaceholder.into()); + } + + 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. 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)] @@ -1888,6 +1975,8 @@ impl MpcContract { .votes .retain(participants); + self.tee_verifier_votes.retain(participants); + Ok(()) } } @@ -1918,6 +2007,12 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); + let tee_verifier_account_id = initial_tee_verifier_account_id( + init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()), + ); + Ok(Self { protocol_state: ProtocolContractState::Running(RunningContractState::new( DomainRegistry::default(), @@ -1941,6 +2036,8 @@ impl MpcContract { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), + tee_verifier_account_id, + tee_verifier_votes: TeeVerifierVotes::default(), }) } @@ -1989,6 +2086,12 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); + let tee_verifier_account_id = initial_tee_verifier_account_id( + init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()), + ); + Ok(MpcContract { config: init_config.map(Into::into).unwrap_or_default(), protocol_state: ProtocolContractState::Running(RunningContractState::new( @@ -2012,6 +2115,8 @@ impl MpcContract { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), + tee_verifier_account_id, + tee_verifier_votes: TeeVerifierVotes::default(), }) } @@ -3831,6 +3936,20 @@ mod tests { (contract, participants, first_participant_id) } + #[test] + #[expect(non_snake_case)] + 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())); + } + fn submit_attestation( contract: &mut MpcContract, participants: &Participants, @@ -4415,6 +4534,8 @@ mod tests { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), + tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_votes: Default::default(), } } } 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 a37c9bc460..4773fbfcf6 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 @@ -699,6 +699,14 @@ BorshSchemaContainer { "foreign_chains", "Lazy", ), + ( + "tee_verifier_account_id", + "AccountId", + ), + ( + "tee_verifier_votes", + "TeeVerifierVotes", + ), ], ), }, @@ -1152,6 +1160,16 @@ BorshSchemaContainer { ], ), }, + "TeeVerifierVotes": Struct { + fields: NamedFields( + [ + ( + "pending", + "Votes", + ), + ], + ), + }, "Threshold": Struct { fields: UnnamedFields( [ @@ -1251,6 +1269,20 @@ BorshSchemaContainer { ], ), }, + "Votes": Struct { + fields: NamedFields( + [ + ( + "proposal_by_voter", + "IterableMap", + ), + ( + "votes_by_proposal", + "IterableMap", + ), + ], + ), + }, "[u8; 32]": Sequence { length_width: 0, length_range: 32..=32, diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index e74aee7374..ca2643cce5 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -32,4 +32,6 @@ pub enum StorageKey { ForeignChainProviderVotesByProposalV1, ForeignChainsConfigs, ForeignChainMetadata, + TeeVerifierVotesByVoterV1, + TeeVerifierVotesByProposalV1, } diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 1a6285a307..9fafd439f9 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -3,3 +3,4 @@ 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/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_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index fe594f68d5..e218be9850 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -15,6 +15,7 @@ use crate::{ Config, SupportedForeignChainsByNode, foreign_chain_rpc::ForeignChainRpcWhitelist, foreign_chains_metadata::ForeignChainsMetadata, + initial_tee_verifier_account_id, node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, @@ -22,7 +23,7 @@ use crate::{ }, state::ProtocolContractState, storage_keys::StorageKey, - tee::tee_state::TeeState, + tee::{tee_state::TeeState, verifier_votes::TeeVerifierVotes}, update::ProposedUpdates, }; @@ -66,6 +67,10 @@ impl From for crate::MpcContract { ..Default::default() }, ), + // New in this version: deployed state predates the TEE verifier, so + // start from the unconfigured placeholder with no pending votes. + tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_votes: TeeVerifierVotes::default(), } } } diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 4621e7d95c..ba487d4df0 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), + // Not part of `Config`, so it does not round-trip through `config()`; + // keep it None so the equality assertion below holds. + tee_verifier_account_id: None, }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() 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 907fc36be8..a2f23b0f95 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1874,6 +1874,42 @@ expression: abi } } }, + { + "name": "vote_tee_verifier_change", + "doc": " Vote for `candidate_account_id` to become the trusted `tee-verifier`\n account. `expected_code_hash` commits the voter to the code they audited.\n When the proposal crosses the signing threshold, `tee_verifier_account_id`\n is updated and all pending verifier-change votes are cleared.", + "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.", @@ -2249,6 +2285,17 @@ expression: abi } } } + }, + { + "name": "withdraw_tee_verifier_vote", + "doc": " Withdraw the caller's current vote on any pending verifier-change\n proposal. No-op if the caller has not voted.", + "kind": "call", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } } ], "root_schema": { @@ -3345,6 +3392,13 @@ expression: abi ], "format": "uint64", "minimum": 0.0 + }, + "tee_verifier_account_id": { + "description": "Account whose `verify_quote` method the contract trusts for DCAP verification. Optional: fresh deploys may set it here, otherwise the contract starts from an unconfigured placeholder and participants vote one in via `vote_tee_verifier_change`.", + "type": [ + "string", + "null" + ] } } }, diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 1f6e6a517d..a3946630c9 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 resubmit 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/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index c0896ddabe..dfa3a6bf13 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -30,6 +30,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..09fbe4c020 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, + /// Account whose `verify_quote` method the contract trusts for DCAP + /// verification. Optional: fresh deploys may set it here, otherwise the + /// contract starts from an unconfigured 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 f19436ec894be286d45726c9469237fef1326615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Thu, 18 Jun 2026 22:27:17 +0200 Subject: [PATCH 09/18] Address PR review: no-op vote guard, intra-doc links, tests, doc nits - vote_tee_verifier_change: short-circuit when the candidate is already the current verifier, so a no-op re-vote can't clear an in-flight rotation. - Add a contract-level unit test driving vote_tee_verifier_change through threshold and asserting the verifier account flips. - Add a strict-subset case to the TeeVerifierVotes::retain test. - Use rustdoc intra-doc links for non-public item references; note the expiry constant's node-side round-trip; fix two doc typos; tighten the clear-on-threshold comment. - Regenerate the ABI snapshot for the reworded method doc. --- crates/contract/src/lib.rs | 64 +++++++++++++++++-- crates/contract/src/tee/verifier_votes.rs | 19 +++--- .../snapshots/abi__abi_has_not_changed.snap | 2 +- crates/mpc-attestation/src/attestation.rs | 5 +- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b26c132aaf..afe653ea3c 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -141,13 +141,14 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } -/// Placeholder `tee_verifier_account_id` before participants have voted in a -/// real verifier. Never deployed and never called; `vote_tee_verifier_change` -/// refuses to vote it in, so the verifier can only move away from it. +/// Placeholder [`MpcContract::tee_verifier_account_id`] before participants have +/// voted in a real verifier. Never deployed and never called; +/// [`MpcContract::vote_tee_verifier_change`] refuses to vote it in, so the +/// verifier can only move away from it. 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`] +/// The [`MpcContract::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(|| { @@ -1686,8 +1687,8 @@ impl MpcContract { /// Vote for `candidate_account_id` to become the trusted `tee-verifier` /// account. `expected_code_hash` commits the voter to the code they audited. - /// When the proposal crosses the signing threshold, `tee_verifier_account_id` - /// is updated and all pending verifier-change votes are cleared. + /// When the proposal crosses the signing threshold, the trusted verifier + /// account is updated and all pending verifier-change votes are cleared. #[handle_result] pub fn vote_tee_verifier_change( &mut self, @@ -1708,6 +1709,12 @@ impl MpcContract { return Err(TeeError::VerifierCandidateIsPlaceholder.into()); } + // Voting in the already-current verifier is a no-op; return without + // recording a vote so it can't clear an in-flight rotation proposal. + if candidate_account_id == self.tee_verifier_account_id { + return Ok(()); + } + let threshold_parameters = self .protocol_state .threshold_parameters() @@ -3950,6 +3957,49 @@ mod tests { assert_eq!(result, Err(TeeError::VerifierCandidateIsPlaceholder.into())); } + #[test] + #[expect(non_snake_case)] + fn vote_tee_verifier_change__should_apply_candidate_when_threshold_reached() { + // Given a running contract with 3 participants, signing threshold 2, + // starting at the unconfigured placeholder verifier. + let (mut contract, participants, _) = setup_tee_test_contract(3, 2); + assert_eq!( + contract.tee_verifier_account_id, + initial_tee_verifier_account_id(None) + ); + let participant_account_ids: Vec = participants + .participants() + .iter() + .map(|(account_id, _, _)| account_id.clone()) + .collect(); + let candidate: AccountId = "verifier.near".parse().unwrap(); + let code_hash = [7u8; 32]; + + let vote_as = |contract: &mut MpcContract, account_id: &AccountId| { + testing_env!( + VMContextBuilder::new() + .signer_account_id(account_id.clone()) + .predecessor_account_id(account_id.clone()) + .build() + ); + contract + .vote_tee_verifier_change(candidate.clone(), code_hash) + .expect("vote should succeed"); + }; + + // When the first participant votes (below threshold), the verifier is unchanged. + vote_as(&mut contract, &participant_account_ids[0]); + assert_eq!( + contract.tee_verifier_account_id, + initial_tee_verifier_account_id(None) + ); + + // When the second participant votes, threshold is reached and the + // candidate becomes the trusted verifier. + vote_as(&mut contract, &participant_account_ids[1]); + assert_eq!(contract.tee_verifier_account_id, candidate); + } + fn submit_attestation( contract: &mut MpcContract, participants: &Participants, diff --git a/crates/contract/src/tee/verifier_votes.rs b/crates/contract/src/tee/verifier_votes.rs index e76ad88a46..8dbf3fcd89 100644 --- a/crates/contract/src/tee/verifier_votes.rs +++ b/crates/contract/src/tee/verifier_votes.rs @@ -1,9 +1,9 @@ //! 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 +//! (`tee_verifier_account_id`), chosen 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. @@ -78,11 +78,9 @@ impl TeeVerifierVotes { })?; 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. + // Clear every pending vote — including losing-hash buckets for the + // same account and votes for other candidates — so a stale quorum + // can't later re-fire against the now-current verifier. self.pending.clear(); Ok(Some(proposal.candidate_account_id)) } else { @@ -270,6 +268,11 @@ mod tests { votes.retain(&participants); assert_eq!(votes.pending_voter_count(), 2); + // Retaining against a strict subset that excludes voter 0 keeps voter 1 + // and drops voter 0. + votes.retain(&participants.subset(1..3)); + assert_eq!(votes.pending_voter_count(), 1); + // 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/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index a2f23b0f95..996d6e0835 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1876,7 +1876,7 @@ expression: abi }, { "name": "vote_tee_verifier_change", - "doc": " Vote for `candidate_account_id` to become the trusted `tee-verifier`\n account. `expected_code_hash` commits the voter to the code they audited.\n When the proposal crosses the signing threshold, `tee_verifier_account_id`\n is updated and all pending verifier-change votes are cleared.", + "doc": " Vote for `candidate_account_id` to become the trusted `tee-verifier`\n account. `expected_code_hash` commits the voter to the code they audited.\n When the proposal crosses the signing threshold, the trusted verifier\n account is updated and all pending verifier-change votes are cleared.", "kind": "call", "params": { "serialization_type": "json", diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index a3946630c9..ad87ffe2a1 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -24,10 +24,13 @@ use crate::alloc::string::{String, ToString}; // TODO(#1639): extract timestamp from certificate itself // // 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 +// one that 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 resubmit cadence, so honest nodes refresh with comfortable // margin. +// +// This constant is also used node-side to recover an attestation's storage +// timestamp from its stored expiry, so changing it shifts that round-trip too. pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24; // 1 day #[expect(clippy::large_enum_variant)] From 8410789558f41ae0b30ece06af6a7224e707a366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Fri, 19 Jun 2026 17:07:51 +0200 Subject: [PATCH 10/18] feat(contract): add TeeVerifierCodeHash newtype for verifier voting Replace the raw CryptoHash on vote_tee_verifier_change with a dedicated TeeVerifierCodeHash newtype defined in mpc-primitives, so the verifier code hash a participant audited is typed end to end. Regenerate the contract ABI snapshot for the new signature, deduplicate the verifier_votes tests, and clarify the DEFAULT_EXPIRATION_DURATION_SECONDS doc comment --- crates/contract/src/lib.rs | 24 +- crates/contract/src/tee/verifier_votes.rs | 256 +++++++++++------- .../snapshots/abi__abi_has_not_changed.snap | 17 +- crates/mpc-attestation/src/attestation.rs | 13 +- crates/primitives/src/hash.rs | 8 + 5 files changed, 191 insertions(+), 127 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index afe653ea3c..3c4beae307 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -72,7 +72,7 @@ use near_mpc_contract_interface::types::{ use near_mpc_contract_interface::{method_names, types::CKDRequestArgs}; use dtos::{Curve, DomainConfig, DomainId, DomainPurpose, Protocol}; -use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; +use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, TeeVerifierCodeHash}; use near_sdk::{ AccountId, CryptoHash, Gas, GasWeight, NearToken, Promise, PromiseError, PromiseOrValue, env, log, near, @@ -182,7 +182,7 @@ pub struct MpcContract { // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chains: Lazy, - /// Account whose `verify_quote` the contract trusts for DCAP verification. + /// The verifier contract account the contract trusts for DCAP verification. /// Starts at the [`UNSET_TEE_VERIFIER_ACCOUNT`] placeholder until /// participants vote one in. Not yet used to dispatch verification (the /// async flow lands in a follow-up); stored and voted on here. @@ -1685,21 +1685,21 @@ impl MpcContract { Ok(applied) } - /// Vote for `candidate_account_id` to become the trusted `tee-verifier` - /// account. `expected_code_hash` commits the voter to the code they audited. - /// When the proposal crosses the signing threshold, the trusted verifier - /// account is updated and all pending verifier-change votes are cleared. + /// Vote for a candidate account to become the trusted verifier contract + /// account, committing to the code hash the voter audited. When the proposal + /// crosses the signing threshold, the trusted verifier account is updated + /// and all pending verifier-change votes are cleared. #[handle_result] pub fn vote_tee_verifier_change( &mut self, candidate_account_id: AccountId, - expected_code_hash: CryptoHash, + expected_code_hash: TeeVerifierCodeHash, ) -> Result<(), Error> { log!( "vote_tee_verifier_change: signer={}, candidate={}, expected_code_hash={}", env::signer_account_id(), candidate_account_id, - hex::encode(expected_code_hash), + expected_code_hash, ); self.voter_or_panic(); @@ -3950,8 +3950,10 @@ mod tests { 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]); + let result = contract.vote_tee_verifier_change( + initial_tee_verifier_account_id(None), + TeeVerifierCodeHash::new([0; 32]), + ); // Then the vote is rejected as the placeholder candidate. assert_eq!(result, Err(TeeError::VerifierCandidateIsPlaceholder.into())); @@ -3973,7 +3975,7 @@ mod tests { .map(|(account_id, _, _)| account_id.clone()) .collect(); let candidate: AccountId = "verifier.near".parse().unwrap(); - let code_hash = [7u8; 32]; + let code_hash = TeeVerifierCodeHash::new([7u8; 32]); let vote_as = |contract: &mut MpcContract, account_id: &AccountId| { testing_env!( diff --git a/crates/contract/src/tee/verifier_votes.rs b/crates/contract/src/tee/verifier_votes.rs index 8dbf3fcd89..77f1e4653d 100644 --- a/crates/contract/src/tee/verifier_votes.rs +++ b/crates/contract/src/tee/verifier_votes.rs @@ -1,31 +1,36 @@ -//! Participant voting for the trusted `tee-verifier` account. +//! Participant voting for the trusted `tee-verifier` contract account. //! -//! `mpc-contract` invokes `verify_quote` on a single trusted verifier account -//! (`tee_verifier_account_id`), chosen 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`. +//! `mpc-contract` verifies quotes against a single trusted verifier contract +//! account, chosen by a threshold vote of active participants, each committing +//! to the `(account_id, code_hash)` pair they audited off-chain. + +use crate::{ + errors::{ConversionError, Error}, + primitives::{ + key_state::AuthenticatedParticipantId, + participants::Participants, + thresholds::ThresholdParameters, + votes::{ProposalHash, ProposalHashEncoding, Votes}, + }, + storage_keys::StorageKey, +}; +use mpc_primitives::hash::TeeVerifierCodeHash; +use near_sdk::{AccountId, near}; +#[cfg(test)] +use std::collections::{BTreeMap, BTreeSet}; + +/// A proposal to point the trusted verifier account at a candidate account. /// -/// `expected_code_hash` makes every yes-voter commit to the exact code they +/// The 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. +/// on its own. The contract consumes only the candidate account once a bucket +/// crosses threshold. #[near(serializers = [borsh])] #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifierChangeProposal { pub candidate_account_id: AccountId, - pub expected_code_hash: CryptoHash, + pub expected_code_hash: TeeVerifierCodeHash, } impl ProposalHashEncoding for VerifierChangeProposal { @@ -34,8 +39,7 @@ impl ProposalHashEncoding for VerifierChangeProposal { } } -/// Pending votes for changing `tee_verifier_account_id`. Each voter is an -/// active MPC participant authenticated via [`AuthenticatedParticipantId`]. +/// Pending votes for changing the trusted verifier account. #[near(serializers=[borsh])] #[derive(Debug)] pub struct TeeVerifierVotes { @@ -54,11 +58,10 @@ impl Default for TeeVerifierVotes { } 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`. + /// Records the participant's vote for the proposal. Returns the winning + /// candidate account once it crosses the signing threshold (votes from + /// dropped participants don't count); on a win, all pending votes are + /// cleared and the caller must apply the new verifier account. pub fn vote( &mut self, proposal: VerifierChangeProposal, @@ -78,9 +81,6 @@ impl TeeVerifierVotes { })?; if count >= protocol_threshold { - // Clear every pending vote — including losing-hash buckets for the - // same account and votes for other candidates — so a stale quorum - // can't later re-fire against the now-current verifier. self.pending.clear(); Ok(Some(proposal.candidate_account_id)) } else { @@ -102,8 +102,8 @@ impl TeeVerifierVotes { } #[cfg(test)] - fn pending_voter_count(&self) -> usize { - self.pending.all().values().map(|s| s.len()).sum() + fn pending_votes(&self) -> BTreeMap> { + self.pending.all() } } @@ -112,17 +112,14 @@ impl TeeVerifierVotes { 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; + use near_sdk::{test_utils::VMContextBuilder, testing_env}; - fn tp(participants: &Participants, n: u64) -> ThresholdParameters { - ThresholdParameters::new_unvalidated(participants.clone(), Threshold::new(n)) + fn threshold_params(participants: &Participants, threshold: u64) -> ThresholdParameters { + ThresholdParameters::new_unvalidated(participants.clone(), Threshold::new(threshold)) } - /// Build `n` participants and pre-authenticate each (env reset before any - /// storage-backed state is touched, mirroring the foreign-chain vote tests). + /// Build `n` participants and pre-authenticate each. fn setup(n: usize) -> (Participants, Vec) { let participants = gen_participants(n); let mut auth_ids = Vec::with_capacity(n); @@ -135,146 +132,209 @@ mod tests { (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], + candidate_account_id: account.parse().unwrap(), + expected_code_hash: TeeVerifierCodeHash::new([hash_byte; 32]), } } + /// Build 3 authenticated participants with the given signing threshold, + /// alongside fresh, empty pending votes. + fn setup_votes( + threshold: u64, + ) -> ( + Participants, + ThresholdParameters, + Vec, + TeeVerifierVotes, + ) { + let (participants, voters) = setup(3); + let params = threshold_params(&participants, threshold); + ( + participants.clone(), + params, + voters, + TeeVerifierVotes::default(), + ) + } + + /// The expected pending-vote map: each `(proposal, voters)` pair becomes a + /// [`ProposalHash`] bucket holding exactly those voters. + fn expected_votes( + buckets: impl IntoIterator)>, + ) -> BTreeMap> { + let mut map = BTreeMap::new(); + for (proposal, voters) in buckets { + let voter_count = voters.len(); + let voter_set: BTreeSet<_> = voters.into_iter().collect(); + assert_eq!( + voter_set.len(), + voter_count, + "duplicate voter in expected bucket" + ); + assert!( + map.insert(proposal.into(), voter_set).is_none(), + "duplicate proposal in expected votes" + ); + } + map + } + #[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(); + let (_participants, params, voters, mut votes) = setup_votes(2); + let proposal = proposal("v.near", 1); // When one participant votes let result = votes - .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .vote(proposal.clone(), voters[0].clone(), ¶ms) .unwrap(); - // Then no candidate wins yet + // Then no candidate wins yet, and the single vote is recorded assert_eq!(result, None); - assert_eq!(votes.pending_voter_count(), 1); + assert_eq!( + votes.pending_votes(), + expected_votes([(proposal, vec![voters[0].clone()])]) + ); } #[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(); + let (_participants, params, voters, mut votes) = setup_votes(2); + let proposal = proposal("v.near", 1); // When two participants vote for the same (account, hash) assert_eq!( votes - .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .vote(proposal.clone(), voters[0].clone(), ¶ms) .unwrap(), None ); let result = votes - .vote(proposal("v.near", 1), voters[1].clone(), ¶ms) + .vote(proposal.clone(), 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); + assert_eq!(result, Some(proposal.candidate_account_id)); + assert_eq!(votes.pending_votes(), BTreeMap::new()); } #[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(); + let (_participants, params, voters, mut votes) = setup_votes(2); + let candidate = "v.near"; + let proposal_hash_1 = proposal(candidate, 1); + let proposal_hash_2 = proposal(candidate, 2); // When two participants vote for the same account but different code hashes assert_eq!( votes - .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .vote(proposal_hash_1.clone(), voters[0].clone(), ¶ms) .unwrap(), None ); let result = votes - .vote(proposal("v.near", 2), voters[1].clone(), ¶ms) + .vote(proposal_hash_2.clone(), voters[1].clone(), ¶ms) .unwrap(); - // Then neither bucket reaches threshold + // Then neither bucket reaches threshold: the two votes land in separate + // (account, hash) buckets. assert_eq!(result, None); - assert_eq!(votes.pending_voter_count(), 2); + assert_eq!( + votes.pending_votes(), + expected_votes([ + (proposal_hash_1, vec![voters[0].clone()]), + (proposal_hash_2, vec![voters[1].clone()]), + ]) + ); } #[test] fn revote__should_replace_previous_vote() { - let (participants, voters) = setup(3); - let params = tp(&participants, 2); - let mut votes = TeeVerifierVotes::default(); + // Given 3 participants, threshold 2 + let (_participants, params, voters, mut votes) = setup_votes(2); + let first_proposal = proposal("a.near", 1); + let second_proposal = proposal("b.near", 1); + // When the same voter votes, then switches to a different candidate votes - .vote(proposal("a.near", 1), voters[0].clone(), ¶ms) + .vote(first_proposal, voters[0].clone(), ¶ms) .unwrap(); - // Same voter switches to a different candidate. votes - .vote(proposal("b.near", 1), voters[0].clone(), ¶ms) + .vote(second_proposal.clone(), 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); + // Then only the b.near vote remains (the a.near bucket is gone); a + // second voter on b.near then crosses. + assert_eq!( + votes.pending_votes(), + expected_votes([(second_proposal.clone(), vec![voters[0].clone()])]) + ); let result = votes - .vote(proposal("b.near", 1), voters[1].clone(), ¶ms) + .vote(second_proposal.clone(), voters[1].clone(), ¶ms) .unwrap(); - assert_eq!(result, Some(candidate("b.near"))); + assert_eq!(result, Some(second_proposal.candidate_account_id)); } #[test] fn withdraw__should_remove_caller_vote() { - let (participants, voters) = setup(3); - let params = tp(&participants, 2); - let mut votes = TeeVerifierVotes::default(); - + // Given 3 participants, threshold 2, and one recorded vote + let (_participants, params, voters, mut votes) = setup_votes(2); + let proposal = proposal("v.near", 1); votes - .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .vote(proposal.clone(), voters[0].clone(), ¶ms) .unwrap(); - assert_eq!(votes.pending_voter_count(), 1); + assert_eq!( + votes.pending_votes(), + expected_votes([(proposal, vec![voters[0].clone()])]) + ); + // When the caller withdraws votes.withdraw(&voters[0]); - assert_eq!(votes.pending_voter_count(), 0); - // No-op for a voter who never voted. + // Then their vote is removed + assert_eq!(votes.pending_votes(), BTreeMap::new()); + + // When a voter who never voted withdraws, it is a no-op votes.withdraw(&voters[1]); - assert_eq!(votes.pending_voter_count(), 0); + assert_eq!(votes.pending_votes(), BTreeMap::new()); } #[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(); - + // Given 3 participants, threshold 3, and two voters sharing one bucket + let (participants, params, voters, mut votes) = setup_votes(3); + let proposal = proposal("v.near", 1); votes - .vote(proposal("v.near", 1), voters[0].clone(), ¶ms) + .vote(proposal.clone(), voters[0].clone(), ¶ms) .unwrap(); votes - .vote(proposal("v.near", 1), voters[1].clone(), ¶ms) + .vote(proposal.clone(), voters[1].clone(), ¶ms) .unwrap(); - assert_eq!(votes.pending_voter_count(), 2); + let both_voters = + expected_votes([(proposal.clone(), vec![voters[0].clone(), voters[1].clone()])]); + assert_eq!(votes.pending_votes(), both_voters); - // Retaining against the same participant set is a no-op. + // When retaining against the same participant set votes.retain(&participants); - assert_eq!(votes.pending_voter_count(), 2); + // Then it is a no-op + assert_eq!(votes.pending_votes(), both_voters); - // Retaining against a strict subset that excludes voter 0 keeps voter 1 - // and drops voter 0. + // When retaining against a strict subset that excludes voter 0 votes.retain(&participants.subset(1..3)); - assert_eq!(votes.pending_voter_count(), 1); + // Then voter 1 is kept and voter 0 is dropped + assert_eq!( + votes.pending_votes(), + expected_votes([(proposal, vec![voters[1].clone()])]) + ); - // Retaining against an empty set (no current participants) drops all votes. + // When retaining against an empty set (no current participants) votes.retain(&gen_participants(0)); - assert_eq!(votes.pending_voter_count(), 0); + // Then all votes are dropped + assert_eq!(votes.pending_votes(), BTreeMap::new()); } } 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 996d6e0835..219e2b59d4 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1876,7 +1876,7 @@ expression: abi }, { "name": "vote_tee_verifier_change", - "doc": " Vote for `candidate_account_id` to become the trusted `tee-verifier`\n account. `expected_code_hash` commits the voter to the code they audited.\n When the proposal crosses the signing threshold, the trusted verifier\n account is updated and all pending verifier-change votes are cleared.", + "doc": " Vote for a candidate account to become the trusted verifier contract\n account, committing to the code hash the voter audited. When the proposal\n crosses the signing threshold, the trusted verifier account is updated\n and all pending verifier-change votes are cleared.", "kind": "call", "params": { "serialization_type": "json", @@ -1891,14 +1891,7 @@ expression: abi { "name": "expected_code_hash", "type_schema": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 + "$ref": "#/definitions/TeeVerifierCodeHash" } } ] @@ -4811,6 +4804,12 @@ expression: abi } } }, + "TeeVerifierCodeHash": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]+$" + }, "Threshold": { "description": "Cryptographic threshold (`k`) for a distributed key: the minimum number of participants that must collaborate to produce a signature.", "type": "integer", diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index ad87ffe2a1..6f4ee638b4 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -21,16 +21,11 @@ use sha2::{Digest as _, Sha256}; use crate::alloc::format; use crate::alloc::string::{String, ToString}; +/// Bounds how long a wrongly-accepted attestation (e.g. one let through by a +/// since-rotated verifier) stays trusted before it ages out via +/// [`VerifiedAttestation::re_verify`]. Well above the node's hourly resubmit +/// cadence, so nodes refresh in time. // TODO(#1639): extract timestamp from certificate itself -// -// 1 day (lowered from 7) bounds how long a wrongly-accepted attestation — e.g. -// one that 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 resubmit cadence, so honest nodes refresh with comfortable -// margin. -// -// This constant is also used node-side to recover an attestation's storage -// timestamp from its stored expiry, so changing it shifts that round-trip too. pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24; // 1 day #[expect(clippy::large_enum_variant)] diff --git a/crates/primitives/src/hash.rs b/crates/primitives/src/hash.rs index 76fdbdbfcf..227b105aa6 100644 --- a/crates/primitives/src/hash.rs +++ b/crates/primitives/src/hash.rs @@ -199,6 +199,14 @@ define_hash!( 32 ); +define_hash!( + /// Wasm code hash of the trusted `tee-verifier` contract. Voted on by + /// participants, each committing to the code they audited, before the + /// verifier account is trusted for DCAP verification. + TeeVerifierCodeHash, + 32 +); + define_hash!( /// A SHA-384 digest used for TDX measurements (MRTD, RTMRs, event digests). Sha384Digest, From 79ac8273e04bf07ef5a4ce341750a7bae3ea35e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 22 Jun 2026 12:27:28 +0200 Subject: [PATCH 11/18] refactor(contract): make tee_verifier_account_id optional, dedup threshold lookup Replace the unset.tee-verifier.invalid placeholder with Option: None now denotes the unconfigured state, so the rollback-to-placeholder guard and the VerifierCandidateIsPlaceholder error are gone (a vote only ever carries a real AccountId, so it can never unset the verifier). The migration starts from None. Collapsing the Option back to a plain AccountId once a verifier is voted in is tracked in TODO(#3639). Extract the copy-pasted "threshold_parameters() or panic on NotInitialized" block from the seven vote methods into ProtocolContractState::threshold_parameters_or_panic. --- crates/contract/src/errors.rs | 4 - crates/contract/src/lib.rs | 134 ++++-------------- ...contract_borsh_schema_has_not_changed.snap | 17 ++- crates/contract/src/state.rs | 8 +- crates/contract/src/v3_12_0_state.rs | 5 +- .../src/types/config.rs | 10 +- 6 files changed, 55 insertions(+), 123 deletions(-) diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index d5c059cef9..4a58fd9a67 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -28,10 +28,6 @@ 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( - "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 3c4beae307..c8a6af4432 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -46,7 +46,6 @@ use crate::{ ckd::{CKDRequest, app_public_key_check, ckd_output_check}, domain::AddDomainsVotes, }, - state::ContractNotInitialized, storage_keys::StorageKey, tee::tee_state::{TeeQuoteStatus, TeeState}, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, @@ -141,23 +140,6 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } -/// Placeholder [`MpcContract::tee_verifier_account_id`] before participants have -/// voted in a real verifier. Never deployed and never called; -/// [`MpcContract::vote_tee_verifier_change`] refuses to vote it in, so the -/// verifier can only move away from it. -const UNSET_TEE_VERIFIER_ACCOUNT: &str = "unset.tee-verifier.invalid"; - -/// The [`MpcContract::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") - }) -} - impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -182,11 +164,11 @@ pub struct MpcContract { // TODO(#2937): Remove via state migration. metrics: Metrics, foreign_chains: Lazy, - /// The verifier contract account the contract trusts for DCAP verification. - /// Starts at the [`UNSET_TEE_VERIFIER_ACCOUNT`] placeholder until - /// participants vote one in. Not yet used to dispatch verification (the - /// async flow lands in a follow-up); stored and voted on here. - tee_verifier_account_id: AccountId, + /// The verifier contract account trusted for DCAP verification, or [`None`] + /// until participants vote one in. Not yet used to dispatch verification. + // TODO(#3639): once participants have voted a verifier in, make this + // non-optional via a migration that requires it be set. + tee_verifier_account_id: Option, tee_verifier_votes: TeeVerifierVotes, } @@ -1463,12 +1445,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = match self.protocol_state.threshold_parameters() { - Ok(threshold_parameters) => threshold_parameters, - Err(ContractNotInitialized) => env::panic_str( - "Contract is not initialized. Can not vote for a new image hash before initialization.", - ), - }; + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let votes = self.tee_state.vote(code_hash, &participant); @@ -1501,12 +1478,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = match self.protocol_state.threshold_parameters() { - Ok(threshold_parameters) => threshold_parameters, - Err(ContractNotInitialized) => env::panic_str( - "Contract is not initialized. Cannot vote for a new launcher hash before initialization.", - ), - }; + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = LauncherVoteAction::Add(launcher_hash); @@ -1539,12 +1511,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = match self.protocol_state.threshold_parameters() { - Ok(threshold_parameters) => threshold_parameters, - Err(ContractNotInitialized) => env::panic_str( - "Contract is not initialized. Cannot vote to remove a launcher hash before initialization.", - ), - }; + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = LauncherVoteAction::Remove(launcher_hash); @@ -1573,12 +1540,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = match self.protocol_state.threshold_parameters() { - Ok(threshold_parameters) => threshold_parameters, - Err(ContractNotInitialized) => env::panic_str( - "Contract is not initialized. Cannot vote for an OS measurement before initialization.", - ), - }; + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = MeasurementVoteAction::Add(measurement.clone()); @@ -1606,12 +1568,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = match self.protocol_state.threshold_parameters() { - Ok(threshold_parameters) => threshold_parameters, - Err(ContractNotInitialized) => env::panic_str( - "Contract is not initialized. Cannot vote to remove an OS measurement before initialization.", - ), - }; + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let action = MeasurementVoteAction::Remove(measurement.clone()); @@ -1703,22 +1660,12 @@ impl MpcContract { ); self.voter_or_panic(); - // Reject the placeholder up front so a quorum can never roll the verifier - // back to the unconfigured state. - if candidate_account_id == initial_tee_verifier_account_id(None) { - return Err(TeeError::VerifierCandidateIsPlaceholder.into()); - } - - // Voting in the already-current verifier is a no-op; return without - // recording a vote so it can't clear an in-flight rotation proposal. - if candidate_account_id == self.tee_verifier_account_id { + // Voting in the already-current verifier is a no-op + if self.tee_verifier_account_id.as_ref() == Some(&candidate_account_id) { return Ok(()); } - let threshold_parameters = self - .protocol_state - .threshold_parameters() - .expect("voter_or_panic() above already errors on NotInitialized"); + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; let proposal = VerifierChangeProposal { @@ -1730,7 +1677,7 @@ impl MpcContract { .vote(proposal, participant, threshold_parameters)? { log!("vote_tee_verifier_change: new verifier = {}", new_verifier); - self.tee_verifier_account_id = new_verifier; + self.tee_verifier_account_id = Some(new_verifier); } Ok(()) } @@ -1745,10 +1692,7 @@ impl MpcContract { ); self.voter_or_panic(); - let threshold_parameters = self - .protocol_state - .threshold_parameters() - .expect("voter_or_panic() above already errors on NotInitialized"); + let threshold_parameters = self.protocol_state.threshold_parameters_or_panic(); let participant = AuthenticatedParticipantId::new(threshold_parameters.participants())?; self.tee_verifier_votes.withdraw(&participant); @@ -2014,11 +1958,9 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); - let tee_verifier_account_id = initial_tee_verifier_account_id( - init_config - .as_ref() - .and_then(|c| c.tee_verifier_account_id.clone()), - ); + let tee_verifier_account_id = init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()); Ok(Self { protocol_state: ProtocolContractState::Running(RunningContractState::new( @@ -2093,11 +2035,9 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); - let tee_verifier_account_id = initial_tee_verifier_account_id( - init_config - .as_ref() - .and_then(|c| c.tee_verifier_account_id.clone()), - ); + let tee_verifier_account_id = init_config + .as_ref() + .and_then(|c| c.tee_verifier_account_id.clone()); Ok(MpcContract { config: init_config.map(Into::into).unwrap_or_default(), @@ -3943,32 +3883,13 @@ mod tests { (contract, participants, first_participant_id) } - #[test] - #[expect(non_snake_case)] - 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), - TeeVerifierCodeHash::new([0; 32]), - ); - - // Then the vote is rejected as the placeholder candidate. - assert_eq!(result, Err(TeeError::VerifierCandidateIsPlaceholder.into())); - } - #[test] #[expect(non_snake_case)] fn vote_tee_verifier_change__should_apply_candidate_when_threshold_reached() { // Given a running contract with 3 participants, signing threshold 2, - // starting at the unconfigured placeholder verifier. + // starting unconfigured. let (mut contract, participants, _) = setup_tee_test_contract(3, 2); - assert_eq!( - contract.tee_verifier_account_id, - initial_tee_verifier_account_id(None) - ); + assert_eq!(contract.tee_verifier_account_id, None); let participant_account_ids: Vec = participants .participants() .iter() @@ -3991,15 +3912,12 @@ mod tests { // When the first participant votes (below threshold), the verifier is unchanged. vote_as(&mut contract, &participant_account_ids[0]); - assert_eq!( - contract.tee_verifier_account_id, - initial_tee_verifier_account_id(None) - ); + assert_eq!(contract.tee_verifier_account_id, None); // When the second participant votes, threshold is reached and the // candidate becomes the trusted verifier. vote_as(&mut contract, &participant_account_ids[1]); - assert_eq!(contract.tee_verifier_account_id, candidate); + assert_eq!(contract.tee_verifier_account_id, Some(candidate)); } fn submit_attestation( @@ -4586,7 +4504,7 @@ mod tests { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), - tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_account_id: None, tee_verifier_votes: Default::default(), } } 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 4773fbfcf6..fb3d087dee 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 @@ -701,7 +701,7 @@ BorshSchemaContainer { ), ( "tee_verifier_account_id", - "AccountId", + "Option", ), ( "tee_verifier_votes", @@ -751,6 +751,21 @@ BorshSchemaContainer { ], ), }, + "Option": Enum { + tag_width: 1, + variants: [ + ( + 0, + "None", + "()", + ), + ( + 1, + "Some", + "AccountId", + ), + ], + }, "Option": Enum { tag_width: 1, variants: [ diff --git a/crates/contract/src/state.rs b/crates/contract/src/state.rs index 4fb03837f1..9d0ba80080 100644 --- a/crates/contract/src/state.rs +++ b/crates/contract/src/state.rs @@ -16,7 +16,7 @@ use crate::primitives::{ use initializing::InitializingContractState; use near_account_id::AccountId; use near_mpc_contract_interface::types::{Curve, DomainConfig, DomainId}; -use near_sdk::near; +use near_sdk::{env, near}; use resharing::ResharingContractState; use running::RunningContractState; @@ -194,6 +194,12 @@ impl ProtocolContractState { } } } + + /// Active threshold parameters, panicking on [`ContractNotInitialized`]. + pub(super) fn threshold_parameters_or_panic(&self) -> &ThresholdParameters { + self.threshold_parameters() + .unwrap_or_else(|ContractNotInitialized| env::panic_str("contract is not initialized")) + } } impl ProtocolContractState { diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index e218be9850..94d9ef3f76 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -15,7 +15,6 @@ use crate::{ Config, SupportedForeignChainsByNode, foreign_chain_rpc::ForeignChainRpcWhitelist, foreign_chains_metadata::ForeignChainsMetadata, - initial_tee_verifier_account_id, node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, @@ -67,9 +66,7 @@ impl From for crate::MpcContract { ..Default::default() }, ), - // New in this version: deployed state predates the TEE verifier, so - // start from the unconfigured placeholder with no pending votes. - tee_verifier_account_id: initial_tee_verifier_account_id(None), + tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), } } diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 09fbe4c020..520b356f5f 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -1,3 +1,5 @@ +use crate::types::primitives::AccountId; + /// The initial configuration parameters for when initializing the contract. /// All fields are optional, as the contract can fill in defaults for any /// missing fields. @@ -47,11 +49,9 @@ 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, - /// Account whose `verify_quote` method the contract trusts for DCAP - /// verification. Optional: fresh deploys may set it here, otherwise the - /// contract starts from an unconfigured placeholder and participants vote - /// one in via `vote_tee_verifier_change`. - pub tee_verifier_account_id: Option, + /// Contract account trusted for DCAP verification. + // TODO(#3639): make non-optional once a verifier has been voted in. + pub tee_verifier_account_id: Option, } /// Configuration parameters of the contract. From a07d1a727b17932bc6594ae13970738286c863a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 22 Jun 2026 14:31:49 +0200 Subject: [PATCH 12/18] test(contract): regenerate ABI snapshot for shortened InitConfig doc The tee_verifier_account_id doc comment on InitConfig was shortened, which flows into the JSON ABI description via JsonSchema. Update the abi snapshot to match so test_abi_has_not_changed passes. --- 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 219e2b59d4..99ea6c3613 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -3387,7 +3387,7 @@ expression: abi "minimum": 0.0 }, "tee_verifier_account_id": { - "description": "Account whose `verify_quote` method the contract trusts for DCAP verification. Optional: fresh deploys may set it here, otherwise the contract starts from an unconfigured placeholder and participants vote one in via `vote_tee_verifier_change`.", + "description": "Contract account trusted for DCAP verification.", "type": [ "string", "null" From 2bac9f85a5e00dcb03348d4d8da36de63bc5de1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 22 Jun 2026 17:10:24 +0200 Subject: [PATCH 13/18] refactor(contract): drop tee_verifier_account_id from InitConfig The field let a fresh deploy seed the trusted verifier account, but nothing used it and it was the only InitConfig field that doesn't round-trip through config() (Config omits it: the verifier is governance state set by the audited-code vote, not a tunable knob). Remove it so the verifier can only be set via vote_tee_verifier_change, and InitConfig and Config become field-identical. The contract's own tee_verifier_account_id field and its migration default (None) are unchanged. --- crates/contract/src/lib.rs | 12 ++---------- .../contract/tests/sandbox/contract_configuration.rs | 3 --- .../tests/snapshots/abi__abi_has_not_changed.snap | 7 ------- .../near-mpc-contract-interface/src/types/config.rs | 7 ------- 4 files changed, 2 insertions(+), 27 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c8a6af4432..c2ef8627c0 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1958,10 +1958,6 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); - let tee_verifier_account_id = init_config - .as_ref() - .and_then(|c| c.tee_verifier_account_id.clone()); - Ok(Self { protocol_state: ProtocolContractState::Running(RunningContractState::new( DomainRegistry::default(), @@ -1985,7 +1981,7 @@ impl MpcContract { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), - tee_verifier_account_id, + tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), }) } @@ -2035,10 +2031,6 @@ impl MpcContract { let initial_participants = parameters.participants(); let tee_state = TeeState::with_mocked_participant_attestations(initial_participants); - let tee_verifier_account_id = init_config - .as_ref() - .and_then(|c| c.tee_verifier_account_id.clone()); - Ok(MpcContract { config: init_config.map(Into::into).unwrap_or_default(), protocol_state: ProtocolContractState::Running(RunningContractState::new( @@ -2062,7 +2054,7 @@ impl MpcContract { StorageKey::ForeignChainMetadata, ForeignChainsMetadata::default(), ), - tee_verifier_account_id, + tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), }) } diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index ba487d4df0..4621e7d95c 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -102,9 +102,6 @@ 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), - // Not part of `Config`, so it does not round-trip through `config()`; - // keep it None so the equality assertion below holds. - tee_verifier_account_id: None, }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() 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 99ea6c3613..0f3b88e4cf 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -3385,13 +3385,6 @@ expression: abi ], "format": "uint64", "minimum": 0.0 - }, - "tee_verifier_account_id": { - "description": "Contract account trusted for DCAP verification.", - "type": [ - "string", - "null" - ] } } }, diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 520b356f5f..af4284dcb5 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -1,5 +1,3 @@ -use crate::types::primitives::AccountId; - /// The initial configuration parameters for when initializing the contract. /// All fields are optional, as the contract can fill in defaults for any /// missing fields. @@ -49,9 +47,6 @@ 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, - /// Contract account trusted for DCAP verification. - // TODO(#3639): make non-optional once a verifier has been voted in. - pub tee_verifier_account_id: Option, } /// Configuration parameters of the contract. @@ -123,7 +118,6 @@ 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(); @@ -173,7 +167,6 @@ 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 76fa0c7a8817626a770fe7f2b5b001b8e30b8a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 22 Jun 2026 17:56:18 +0200 Subject: [PATCH 14/18] refactor(contract): give verifier-vote sweep its own post-resharing cleanup The tee_verifier_votes.retain sweep was hidden inside clean_foreign_chain_data, whose name and doc are about foreign-chain data. Move it into a dedicated remove_non_participant_tee_verifier_votes cleanup method, spawned as its own detached promise after resharing alongside the sibling cleanups, with its own gas config knob. Each cleanup concern is now self-contained. --- crates/contract/src/config.rs | 6 ++++ crates/contract/src/dto_mapping.rs | 7 ++++ crates/contract/src/lib.rs | 32 +++++++++++++++++ ...contract_borsh_schema_has_not_changed.snap | 4 +++ .../tests/sandbox/contract_configuration.rs | 1 + .../sandbox/upgrade_from_current_contract.rs | 1 + .../snapshots/abi__abi_has_not_changed.snap | 34 +++++++++++++++++++ .../src/method_names.rs | 2 ++ .../src/types/config.rs | 6 ++++ crates/test-utils/src/contract_types.rs | 1 + 10 files changed, 94 insertions(+) diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index c1a06aef25..9acf2a28ca 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -32,6 +32,8 @@ 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; +/// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call +const DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS: u64 = 5; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -64,6 +66,8 @@ 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, + /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. + pub(crate) remove_non_participant_tee_verifier_votes_tera_gas: u64, } impl Default for Config { @@ -88,6 +92,8 @@ 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, + remove_non_participant_tee_verifier_votes_tera_gas: + DEFAULT_REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index b2d71e2232..104870e261 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -487,6 +487,9 @@ 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.remove_non_participant_tee_verifier_votes_tera_gas { + config.remove_non_participant_tee_verifier_votes_tera_gas = v; + } config } @@ -514,6 +517,8 @@ 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, + remove_non_participant_tee_verifier_votes_tera_gas: value + .remove_non_participant_tee_verifier_votes_tera_gas, } } } @@ -540,6 +545,8 @@ 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, + remove_non_participant_tee_verifier_votes_tera_gas: value + .remove_non_participant_tee_verifier_votes_tera_gas, } } } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index c2ef8627c0..593deb7e04 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -1266,6 +1266,18 @@ impl MpcContract { Gas::from_tgas(self.config.clean_foreign_chain_data_tera_gas), ) .detach(); + // Spawn a promise to drop verifier-change votes cast by non-participants + Promise::new(env::current_account_id()) + .function_call( + method_names::REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES.to_string(), + vec![], + NearToken::from_yoctonear(0), + Gas::from_tgas( + self.config + .remove_non_participant_tee_verifier_votes_tera_gas, + ), + ) + .detach(); } Ok(()) @@ -1926,6 +1938,26 @@ impl MpcContract { .votes .retain(participants); + Ok(()) + } + + /// Private endpoint to drop verifier-change votes cast by non-participants + /// after resharing. + #[private] + #[handle_result] + pub fn remove_non_participant_tee_verifier_votes(&mut self) -> Result<(), Error> { + log!( + "remove_non_participant_tee_verifier_votes: signer={}", + env::signer_account_id() + ); + + let participants = match &self.protocol_state { + ProtocolContractState::Running(state) => state.parameters.participants(), + _ => { + return Err(InvalidState::ProtocolStateNotRunning.into()); + } + }; + self.tee_verifier_votes.retain(participants); Ok(()) 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 fb3d087dee..8f1b147a31 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 @@ -254,6 +254,10 @@ BorshSchemaContainer { "clean_foreign_chain_data_tera_gas", "u64", ), + ( + "remove_non_participant_tee_verifier_votes_tera_gas", + "u64", + ), ], ), }, diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 4621e7d95c..83e370a2f0 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -102,6 +102,7 @@ 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), + remove_non_participant_tee_verifier_votes_tera_gas: Some(14), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index 1900cf325b..ef9d4e712b 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -118,6 +118,7 @@ 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, + remove_non_participant_tee_verifier_votes_tera_gas: 14, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); 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 0f3b88e4cf..81a29ff961 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1002,6 +1002,10 @@ expression: abi [ "clean_foreign_chain_data_tera_gas", "u64" + ], + [ + "remove_non_participant_tee_verifier_votes_tera_gas", + "u64" ] ] }, @@ -1206,6 +1210,20 @@ expression: abi } } }, + { + "name": "remove_non_participant_tee_verifier_votes", + "doc": " Private endpoint to drop verifier-change votes cast by non-participants\n after resharing.", + "kind": "call", + "modifiers": [ + "private" + ], + "result": { + "serialization_type": "json", + "type_schema": { + "type": "null" + } + } + }, { "name": "remove_non_participant_update_votes", "doc": " Cleans update votes from non-participants after resharing.\n Can only be called by participants or by the contract itself.", @@ -2672,6 +2690,7 @@ expression: abi "contract_upgrade_deposit_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", + "remove_non_participant_tee_verifier_votes_tera_gas", "remove_non_participant_update_votes_tera_gas", "return_ck_and_clean_state_on_success_call_tera_gas", "return_signature_and_clean_state_on_success_call_tera_gas", @@ -2727,6 +2746,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "remove_non_participant_tee_verifier_votes_tera_gas": { + "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", + "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", @@ -3341,6 +3366,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "remove_non_participant_tee_verifier_votes_tera_gas": { + "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", + "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": [ diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index dfa3a6bf13..a6ae25a80d 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -55,6 +55,8 @@ pub const CLEANUP_ORPHANED_NODE_MIGRATIONS: &str = "cleanup_orphaned_node_migrat 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"; +pub const REMOVE_NON_PARTICIPANT_TEE_VERIFIER_VOTES: &str = + "remove_non_participant_tee_verifier_votes"; // Callbacks (used in promise_yield_create and indexed by the node) pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index af4284dcb5..75646b5a5a 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -47,6 +47,8 @@ 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, + /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. + pub remove_non_participant_tee_verifier_votes_tera_gas: Option, } /// Configuration parameters of the contract. @@ -95,6 +97,8 @@ 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, + /// Prepaid gas for a `remove_non_participant_tee_verifier_votes` call. + pub remove_non_participant_tee_verifier_votes_tera_gas: u64, } #[cfg(test)] @@ -118,6 +122,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), + remove_non_participant_tee_verifier_votes_tera_gas: Some(5), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -167,6 +172,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, + remove_non_participant_tee_verifier_votes_tera_gas: None, }; assert_eq!(default_config, config_with_all_values_as_none); diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 37aca15560..6334ab1536 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -14,5 +14,6 @@ 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, + remove_non_participant_tee_verifier_votes_tera_gas: value + 13, } } From 802e9b4840bef3fb049320036e85fbc2088c4c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Mon, 22 Jun 2026 18:15:49 +0200 Subject: [PATCH 15/18] fix(contract): migrate old Config layout and bump reshare gas Adding the verifier-vote cleanup broke two things: - The sixth detached cleanup promise in vote_reshared pushed total cleanup gas to 39 TGas, over the fixed GAS_FOR_VOTE_RESHARED test budget. Raise it to 50 TGas (production attaches max gas, so it is unaffected). - The new remove_non_participant_tee_verifier_votes_tera_gas field changed Config's borsh layout, so migrate() could no longer deserialize production state written with the old layout. Shadow the old 13-field Config as OldConfig in the v3.12.0 migration state and convert it into the current Config, defaulting the new field. --- crates/contract/src/v3_12_0_state.rs | 54 +++++++++++++++++-- crates/contract/tests/sandbox/utils/consts.rs | 2 +- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index 94d9ef3f76..e5e4a3d276 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -12,7 +12,7 @@ use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionReques use near_sdk::store::{Lazy, LookupMap}; use crate::{ - Config, SupportedForeignChainsByNode, + SupportedForeignChainsByNode, foreign_chain_rpc::ForeignChainRpcWhitelist, foreign_chains_metadata::ForeignChainsMetadata, node_migrations::NodeMigrations, @@ -26,6 +26,54 @@ use crate::{ update::ProposedUpdates, }; +/// The `Config` layout written by the `3.12.0` contract, before +/// `remove_non_participant_tee_verifier_votes_tera_gas` was appended. +#[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 crate::Config { + fn from(old: OldConfig) -> Self { + crate::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, + // New in this version: default the gas for the verifier-vote cleanup + // promise added after `3.12.0`. + ..crate::Config::default() + } + } +} + /// Keep this module in sync with [`crate::MpcContract`]: the moment a field's borsh /// layout diverges, shadow the old type here (see this module's history for examples) so /// state written by the `3.12.0` contract still deserializes during migration. @@ -37,7 +85,7 @@ pub struct MpcContract { pending_verify_foreign_tx_requests: LookupMap>, proposed_updates: ProposedUpdates, node_foreign_chain_support: SupportedForeignChainsByNode, - config: Config, + config: OldConfig, tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, @@ -54,7 +102,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, diff --git a/crates/contract/tests/sandbox/utils/consts.rs b/crates/contract/tests/sandbox/utils/consts.rs index 2be44156d8..b122dad2f6 100644 --- a/crates/contract/tests/sandbox/utils/consts.rs +++ b/crates/contract/tests/sandbox/utils/consts.rs @@ -17,7 +17,7 @@ pub const ALL_PROTOCOLS: &[Protocol; 4] = &[ /// gas attachment; in practice, nodes usually attach the maximum available gas. For testing, /// we use this constant to attach a fixed amount to each call and detect if gas usage /// increases unexpectedly in the future. -pub const GAS_FOR_VOTE_RESHARED: Gas = Gas::from_tgas(44); +pub const GAS_FOR_VOTE_RESHARED: Gas = Gas::from_tgas(50); pub const GAS_FOR_VOTE_PK: Gas = Gas::from_tgas(22); pub const GAS_FOR_VOTE_CANCEL_KEYGEN: Gas = Gas::from_tgas(5); pub const GAS_FOR_VOTE_CANCEL_RESHARING: Gas = Gas::from_tgas(5); From 5fc2819b345f6cd398f60ff75801cd5e0d378990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 23 Jun 2026 11:56:32 +0200 Subject: [PATCH 16/18] refactor(contract): drop V1 suffix from TEE verifier vote storage keys --- crates/contract/src/storage_keys.rs | 4 ++-- crates/contract/src/tee/verifier_votes.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index ca2643cce5..e4e0349c6d 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -32,6 +32,6 @@ pub enum StorageKey { ForeignChainProviderVotesByProposalV1, ForeignChainsConfigs, ForeignChainMetadata, - TeeVerifierVotesByVoterV1, - TeeVerifierVotesByProposalV1, + TeeVerifierVotesByVoter, + TeeVerifierVotesByProposal, } diff --git a/crates/contract/src/tee/verifier_votes.rs b/crates/contract/src/tee/verifier_votes.rs index 77f1e4653d..202aa8610d 100644 --- a/crates/contract/src/tee/verifier_votes.rs +++ b/crates/contract/src/tee/verifier_votes.rs @@ -50,8 +50,8 @@ impl Default for TeeVerifierVotes { fn default() -> Self { Self { pending: Votes::new( - StorageKey::TeeVerifierVotesByVoterV1, - StorageKey::TeeVerifierVotesByProposalV1, + StorageKey::TeeVerifierVotesByVoter, + StorageKey::TeeVerifierVotesByProposal, ), } } From c48bea608a723f8cfce72f7c7fa83dc7493e9619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 23 Jun 2026 12:04:01 +0200 Subject: [PATCH 17/18] docs(attestation): clarify expiry applies to all accepted attestations --- crates/mpc-attestation/src/attestation.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 6f4ee638b4..25585d68ea 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -21,10 +21,9 @@ use sha2::{Digest as _, Sha256}; use crate::alloc::format; use crate::alloc::string::{String, ToString}; -/// Bounds how long a wrongly-accepted attestation (e.g. one let through by a -/// since-rotated verifier) stays trusted before it ages out via -/// [`VerifiedAttestation::re_verify`]. Well above the node's hourly resubmit -/// cadence, so nodes refresh in time. +/// How long an accepted attestation stays trusted before it must be +/// re-verified via [`VerifiedAttestation::re_verify`]. Nodes resubmit hourly, +/// well within this window, so valid attestations refresh in time. // TODO(#1639): extract timestamp from certificate itself pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24; // 1 day From 337ab104534185648820784a803b4b75bff364bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20B=C4=99za?= Date: Tue, 23 Jun 2026 13:22:37 +0200 Subject: [PATCH 18/18] feat(contract): expose tee_verifier_votes view and test post-resharing cleanup --- crates/contract/src/lib.rs | 71 +++++++++++++++++++ crates/contract/src/tee/verifier_votes.rs | 27 ++++--- .../snapshots/abi__abi_has_not_changed.snap | 26 +++++++ 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 24ae8a2e87..dfee3449c5 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -45,6 +45,7 @@ use crate::{ primitives::{ ckd::{CKDRequest, app_public_key_check, ckd_output_check}, domain::AddDomainsVotes, + votes::ProposalHash, }, storage_keys::StorageKey, tee::tee_state::{TeeQuoteStatus, TeeState}, @@ -2118,6 +2119,13 @@ impl MpcContract { self.tee_state.votes.clone() } + /// Returns the pending TEE verifier-change votes, keyed by proposal. + pub fn tee_verifier_votes( + &self, + ) -> BTreeMap> { + self.tee_verifier_votes.pending() + } + /// Presence check for a pending signature request, exposed as a view call. /// /// **The returned `YieldIndex` is an arbitrary representative, not "the" yield @@ -3903,6 +3911,69 @@ mod tests { assert_eq!(contract.tee_verifier_account_id, Some(candidate)); } + #[test] + #[expect(non_snake_case)] + fn remove_non_participant_tee_verifier_votes__should_drop_votes_from_dropped_participants() { + // Given a running contract with 3 participants, signing threshold 3, where + // two participants have cast votes for distinct candidates (neither crosses + // threshold, so both stay pending). + let (mut contract, participants, _) = setup_tee_test_contract(3, 3); + let voters = participant_account_ids(&contract); + let code_hash = TeeVerifierCodeHash::new([7u8; 32]); + + // Vote as `account_id` for `candidate`, returning that voter's authenticated id. + let vote_as = + |contract: &mut MpcContract, account_id: &AccountId, candidate: &AccountId| { + Environment::new(None, Some(account_id.clone()), None); + contract + .vote_tee_verifier_change(candidate.clone(), code_hash) + .expect("vote should succeed"); + AuthenticatedParticipantId::new(&participants).unwrap() + }; + + // The single-voter pending bucket: proposal(candidate) -> {voter}. + let bucket = |candidate: &AccountId, voter: &AuthenticatedParticipantId| { + let proposal = VerifierChangeProposal { + candidate_account_id: candidate.clone(), + expected_code_hash: code_hash, + }; + ( + ProposalHash::from(proposal), + BTreeSet::from([voter.clone()]), + ) + }; + + let candidate_a: AccountId = "verifier-a.near".parse().unwrap(); + let candidate_b: AccountId = "verifier-b.near".parse().unwrap(); + let auth_a = vote_as(&mut contract, &voters[0], &candidate_a); + let auth_b = vote_as(&mut contract, &voters[1], &candidate_b); + + // Then both single-voter buckets are pending. + assert_eq!( + contract.tee_verifier_votes(), + BTreeMap::from([bucket(&candidate_a, &auth_a), bucket(&candidate_b, &auth_b)]), + ); + + // When resharing drops the first participant and the post-resharing cleanup runs. + { + let ProtocolContractState::Running(ref mut state) = contract.protocol_state else { + panic!("expected Running"); + }; + state.parameters = + ThresholdParameters::new(participants.subset(1..3), Threshold::new(2)).unwrap(); + } + Environment::new(None, Some(env::current_account_id()), None); + contract + .remove_non_participant_tee_verifier_votes() + .unwrap(); + + // Then only the still-participant's vote (candidate B) remains. + assert_eq!( + contract.tee_verifier_votes(), + BTreeMap::from([bucket(&candidate_b, &auth_b)]), + ); + } + fn submit_attestation( contract: &mut MpcContract, participants: &Participants, diff --git a/crates/contract/src/tee/verifier_votes.rs b/crates/contract/src/tee/verifier_votes.rs index 202aa8610d..ee37346f7c 100644 --- a/crates/contract/src/tee/verifier_votes.rs +++ b/crates/contract/src/tee/verifier_votes.rs @@ -16,7 +16,6 @@ use crate::{ }; use mpc_primitives::hash::TeeVerifierCodeHash; use near_sdk::{AccountId, near}; -#[cfg(test)] use std::collections::{BTreeMap, BTreeSet}; /// A proposal to point the trusted verifier account at a candidate account. @@ -101,8 +100,8 @@ impl TeeVerifierVotes { .retain_votes(|p| current.is_participant_given_participant_id(&p.get())); } - #[cfg(test)] - fn pending_votes(&self) -> BTreeMap> { + /// Pending votes keyed by proposal. + pub fn pending(&self) -> BTreeMap> { self.pending.all() } } @@ -195,7 +194,7 @@ mod tests { // Then no candidate wins yet, and the single vote is recorded assert_eq!(result, None); assert_eq!( - votes.pending_votes(), + votes.pending(), expected_votes([(proposal, vec![voters[0].clone()])]) ); } @@ -219,7 +218,7 @@ mod tests { // Then the candidate wins and all pending votes are cleared assert_eq!(result, Some(proposal.candidate_account_id)); - assert_eq!(votes.pending_votes(), BTreeMap::new()); + assert_eq!(votes.pending(), BTreeMap::new()); } #[test] @@ -245,7 +244,7 @@ mod tests { // (account, hash) buckets. assert_eq!(result, None); assert_eq!( - votes.pending_votes(), + votes.pending(), expected_votes([ (proposal_hash_1, vec![voters[0].clone()]), (proposal_hash_2, vec![voters[1].clone()]), @@ -271,7 +270,7 @@ mod tests { // Then only the b.near vote remains (the a.near bucket is gone); a // second voter on b.near then crosses. assert_eq!( - votes.pending_votes(), + votes.pending(), expected_votes([(second_proposal.clone(), vec![voters[0].clone()])]) ); let result = votes @@ -289,7 +288,7 @@ mod tests { .vote(proposal.clone(), voters[0].clone(), ¶ms) .unwrap(); assert_eq!( - votes.pending_votes(), + votes.pending(), expected_votes([(proposal, vec![voters[0].clone()])]) ); @@ -297,11 +296,11 @@ mod tests { votes.withdraw(&voters[0]); // Then their vote is removed - assert_eq!(votes.pending_votes(), BTreeMap::new()); + assert_eq!(votes.pending(), BTreeMap::new()); // When a voter who never voted withdraws, it is a no-op votes.withdraw(&voters[1]); - assert_eq!(votes.pending_votes(), BTreeMap::new()); + assert_eq!(votes.pending(), BTreeMap::new()); } #[test] @@ -317,24 +316,24 @@ mod tests { .unwrap(); let both_voters = expected_votes([(proposal.clone(), vec![voters[0].clone(), voters[1].clone()])]); - assert_eq!(votes.pending_votes(), both_voters); + assert_eq!(votes.pending(), both_voters); // When retaining against the same participant set votes.retain(&participants); // Then it is a no-op - assert_eq!(votes.pending_votes(), both_voters); + assert_eq!(votes.pending(), both_voters); // When retaining against a strict subset that excludes voter 0 votes.retain(&participants.subset(1..3)); // Then voter 1 is kept and voter 0 is dropped assert_eq!( - votes.pending_votes(), + votes.pending(), expected_votes([(proposal, vec![voters[1].clone()])]) ); // When retaining against an empty set (no current participants) votes.retain(&gen_participants(0)); // Then all votes are dropped - assert_eq!(votes.pending_votes(), BTreeMap::new()); + assert_eq!(votes.pending(), BTreeMap::new()); } } 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 81a29ff961..e522f87328 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1565,6 +1565,24 @@ expression: abi } } }, + { + "name": "tee_verifier_votes", + "doc": " Returns the pending TEE verifier-change votes, keyed by proposal.", + "kind": "view", + "result": { + "serialization_type": "json", + "type_schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthenticatedParticipantId2" + }, + "uniqueItems": true + } + } + } + }, { "name": "update_config", "kind": "call", @@ -2436,6 +2454,14 @@ expression: abi } ] }, + "AuthenticatedParticipantId2": { + "description": "This struct is supposed to contain the participant id associated to the account `env::signer_account_id()`, but is only constructible given a set of participants that includes the signer, thus acting as a type system-based enforcement mechanism (albeit a best-effort one) for authenticating the signer.", + "allOf": [ + { + "$ref": "#/definitions/ParticipantId" + } + ] + }, "AvailableForeignChains": { "description": "The set of foreign chains available across the threshold of active participants. Returned by `get_available_foreign_chains`; computed from the per-node [`ForeignChainsConfig`] reports.", "type": "array",