diff --git a/Cargo.lock b/Cargo.lock index da7e592957..8bb805b617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5773,6 +5773,7 @@ version = "3.13.0" dependencies = [ "anyhow", "assert_matches", + "attestation", "blstrs", "borsh", "cargo-near-build", @@ -5807,6 +5808,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "signature", + "tee-verifier-interface", "test-utils", "thiserror 2.0.18", "threshold-signatures", @@ -11238,6 +11240,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "test-tee-verifier" +version = "3.13.0" +dependencies = [ + "borsh", + "getrandom 0.2.17", + "near-sdk", + "tee-verifier-interface", +] + [[package]] name = "test-utils" version = "3.13.0" diff --git a/Cargo.toml b/Cargo.toml index 7130c04665..2afdb767b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/test-migration-contract", "crates/test-parallel-contract", "crates/test-port-allocator", + "crates/test-tee-verifier", "crates/test-utils", "crates/threshold-signatures", "crates/tls", diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 8a2f7c4eb2..e9d8f991a6 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -65,6 +65,7 @@ abi = [ "near-mpc-contract-interface/abi", "mpc-attestation/abi", "mpc-primitives/abi", + "tee-verifier-interface/borsh-schema", "schemars", ] # This is used when running `cargo clippy --all-features`, because otherwise `abi` feat will break compilation. @@ -74,6 +75,7 @@ __abi-generate = ["abi", "near-sdk/__abi-generate"] [dependencies] assert_matches = { workspace = true } +attestation = { workspace = true } blstrs = { workspace = true } borsh = { workspace = true } curve25519-dalek = { workspace = true } @@ -87,7 +89,7 @@ k256 = { workspace = true, features = [ "arithmetic", "expose-field", ] } -mpc-attestation = { workspace = true, features = ["local-verify"] } +mpc-attestation = { workspace = true } mpc-primitives = { workspace = true } near-account-id = { workspace = true, features = ["serde"] } near-mpc-bounded-collections = { workspace = true } @@ -102,6 +104,7 @@ rand = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +tee-verifier-interface = { workspace = true } thiserror = { workspace = true } threshold-signatures = { workspace = true, optional = true } diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index 9acf2a28ca..b94868caf9 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -21,6 +21,8 @@ const DEFAULT_RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7 const DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS: u64 = 7; /// Prepaid gas for a `fail_on_timeout` call const DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS: u64 = 2; +/// Prepaid gas for a `fail_attestation_submission` call +const DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS: u64 = 2; /// Prepaid gas for a `clean_tee_status` call const DEFAULT_CLEAN_TEE_STATUS_TERA_GAS: u64 = 10; /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -34,6 +36,15 @@ const DEFAULT_REMOVE_NON_PARTICIPANT_UPDATE_VOTES_TERA_GAS: u64 = 5; 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; +/// Gas attached to the cross-contract `verify_quote` call on the TEE verifier. +const DEFAULT_VERIFIER_TERA_GAS: u64 = 100; +/// Prepaid gas for the `resolve_verification` callback. Carries the bulk of the +/// post-DCAP work (allowlist match, RTMR3 replay, app-compose validation, store). +const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; +/// Prepaid gas for the `on_attestation_verified` yield-callback. Sized for its +/// heaviest (timeout) branch, which removes the pending entry and schedules both +/// a refund transfer and the `fail_attestation_submission` promise. +const DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS: u64 = 10; /// Config for V2 of the contract. #[near(serializers=[borsh, json])] @@ -56,6 +67,8 @@ pub(crate) struct Config { pub(crate) return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub(crate) fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub(crate) fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub(crate) clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -68,6 +81,12 @@ pub(crate) struct Config { 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, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub(crate) verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub(crate) resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub(crate) on_attestation_verified_tera_gas: u64, } impl Default for Config { @@ -85,6 +104,7 @@ impl Default for Config { return_ck_and_clean_state_on_success_call_tera_gas: DEFAULT_RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS_CALL_TERA_GAS, fail_on_timeout_tera_gas: DEFAULT_FAIL_ON_TIMEOUT_TERA_GAS, + fail_attestation_submission_tera_gas: DEFAULT_FAIL_ATTESTATION_SUBMISSION_TERA_GAS, clean_tee_status_tera_gas: DEFAULT_CLEAN_TEE_STATUS_TERA_GAS, clean_invalid_attestations_tera_gas: DEFAULT_CLEAN_INVALID_ATTESTATIONS_TERA_GAS, cleanup_orphaned_node_migrations_tera_gas: @@ -94,6 +114,9 @@ impl Default for Config { 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, + verifier_tera_gas: DEFAULT_VERIFIER_TERA_GAS, + resolve_verification_tera_gas: DEFAULT_RESOLVE_VERIFICATION_TERA_GAS, + on_attestation_verified_tera_gas: DEFAULT_ON_ATTESTATION_VERIFIED_TERA_GAS, } } } diff --git a/crates/contract/src/dto_mapping.rs b/crates/contract/src/dto_mapping.rs index 104870e261..e4a6f7c6a5 100644 --- a/crates/contract/src/dto_mapping.rs +++ b/crates/contract/src/dto_mapping.rs @@ -472,6 +472,9 @@ impl From for Config { if let Some(v) = config_ext.fail_on_timeout_tera_gas { config.fail_on_timeout_tera_gas = v; } + if let Some(v) = config_ext.fail_attestation_submission_tera_gas { + config.fail_attestation_submission_tera_gas = v; + } if let Some(v) = config_ext.clean_tee_status_tera_gas { config.clean_tee_status_tera_gas = v; } @@ -490,6 +493,15 @@ impl From for Config { if let Some(v) = config_ext.remove_non_participant_tee_verifier_votes_tera_gas { config.remove_non_participant_tee_verifier_votes_tera_gas = v; } + if let Some(v) = config_ext.verifier_tera_gas { + config.verifier_tera_gas = v; + } + if let Some(v) = config_ext.resolve_verification_tera_gas { + config.resolve_verification_tera_gas = v; + } + if let Some(v) = config_ext.on_attestation_verified_tera_gas { + config.on_attestation_verified_tera_gas = v; + } config } @@ -510,6 +522,7 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -519,6 +532,9 @@ impl From<&Config> for near_mpc_contract_interface::types::Config { 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, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } @@ -538,6 +554,7 @@ impl From for Config { return_ck_and_clean_state_on_success_call_tera_gas: value .return_ck_and_clean_state_on_success_call_tera_gas, fail_on_timeout_tera_gas: value.fail_on_timeout_tera_gas, + fail_attestation_submission_tera_gas: value.fail_attestation_submission_tera_gas, clean_tee_status_tera_gas: value.clean_tee_status_tera_gas, clean_invalid_attestations_tera_gas: value.clean_invalid_attestations_tera_gas, cleanup_orphaned_node_migrations_tera_gas: value @@ -547,6 +564,9 @@ impl From for Config { 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, + verifier_tera_gas: value.verifier_tera_gas, + resolve_verification_tera_gas: value.resolve_verification_tera_gas, + on_attestation_verified_tera_gas: value.on_attestation_verified_tera_gas, } } } diff --git a/crates/contract/src/errors.rs b/crates/contract/src/errors.rs index 7c67f0c100..f9bd9fbf96 100644 --- a/crates/contract/src/errors.rs +++ b/crates/contract/src/errors.rs @@ -1,6 +1,7 @@ use crate::crypto_shared::kdf::TweakNotOnCurve; use crate::primitives::domain::MIN_RECONSTRUCTION_THRESHOLD; use crate::primitives::key_state::{EpochId, Keyset}; +use crate::tee::tee_state::AttestationSubmissionError; use near_account_id::AccountId; use near_mpc_contract_interface::types as dtos; use near_mpc_contract_interface::types::{DomainId, DomainPurpose, ForeignChain, Protocol}; @@ -28,6 +29,14 @@ pub enum TeeError { "Due to previously failed TEE validation, the network is not accepting new requests at this point in time. Try again later." )] TeeValidationFailed, + #[error( + "A Dstack attestation verification is already in flight for this account; wait for it to finish before resubmitting." + )] + VerificationAlreadyPending, + #[error( + "No TEE verifier is configured yet. Participants must vote one in via vote_tee_verifier_change before Dstack attestations can be submitted." + )] + VerifierNotConfigured, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -318,6 +327,9 @@ pub enum Error { // Tee errors #[error(transparent)] NodeMigrationError(#[from] NodeMigrationError), + // Tee attestation submission errors + #[error(transparent)] + AttestationSubmission(#[from] AttestationSubmissionError), } impl near_sdk::FunctionError for Error { diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index dfee3449c5..9e927a54c8 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -48,6 +48,7 @@ use crate::{ votes::ProposalHash, }, storage_keys::StorageKey, + tee::pending_attestation::{AttestationResult, PendingAttestation}, tee::tee_state::{TeeQuoteStatus, TeeState}, tee::verifier_votes::{TeeVerifierVotes, VerifierChangeProposal}, update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId}, @@ -71,6 +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_attestation::attestation::{Attestation, DstackAttestation}; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, TeeVerifierCodeHash}; use near_sdk::{ AccountId, CryptoHash, Gas, GasWeight, NearToken, Promise, PromiseError, PromiseOrValue, env, @@ -86,11 +88,12 @@ use primitives::{ }; use tee::measurements::{ContractExpectedMeasurements, MeasurementVoteAction, MeasurementVotes}; use tee::proposal::{CodeHashesVotes, LauncherHashVotes}; +use tee_verifier_interface::{VerificationResult, VerifiedReport}; use state::{ProtocolContractState, running::RunningContractState}; use tee::{ proposal::{LauncherVoteAction, NodeImageHash}, - tee_state::{AttestationSubmissionError, NodeId, ParticipantInsertion, TeeValidationResult}, + tee_state::{NodeId, ParticipantInsertion, TeeValidationResult}, }; /// Register used to receive data id from `promise_await_data`. @@ -140,6 +143,16 @@ fn require_deposit(minimum_deposit: NearToken, predecessor: &AccountId) { } } +/// Refunds an attestation submitter's attached deposit (no-op for a zero +/// deposit). Used when an [`Attestation::Dstack`] verification is rejected or +/// times out. +fn refund_attestation_deposit(account_id: &AccountId, deposit: NearToken) { + if deposit > NearToken::from_yoctonear(0) { + log!("refund attestation deposit {deposit} to {account_id}"); + Promise::new(account_id.clone()).transfer(deposit).detach(); + } +} + impl Default for MpcContract { fn default() -> Self { env::panic_str("Calling default not allowed."); @@ -165,11 +178,17 @@ pub struct MpcContract { metrics: Metrics, foreign_chains: Lazy, /// The verifier contract account trusted for DCAP verification, or [`None`] - /// until participants vote one in. Not yet used to dispatch verification. + /// until participants vote one in. An [`Attestation::Dstack`] submission + /// offloads quote verification to this account; while it is [`None`], such + /// submissions are rejected with [`TeeError::VerifierNotConfigured`]. // 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, + /// In-flight [`Attestation::Dstack`] verifications, one entry per submitter + /// account, held between the cross-contract verify-quote call and its + /// resolution (or the yield timeout). + pending_attestations: LookupMap, } #[near(serializers=[borsh])] @@ -753,8 +772,14 @@ impl MpcContract { ) } - /// (Prospective) Participants can submit their tee participant information through this - /// endpoint. + /// Submit a TEE attestation for a current or prospective participant. + /// + /// - [`Attestation::Mock`] is verified synchronously. + /// - [`Attestation::Dstack`] is verified asynchronously, by yielding on a + /// cross-contract verify-quote call. It rejects a second submission from + /// the same account while one is still in flight. + /// + /// The attached deposit pays for storage on success, and is refunded on failure. #[payable] #[handle_result] pub fn submit_participant_info( @@ -775,13 +800,6 @@ impl MpcContract { account_key ); - // Save the initial storage usage to know how much to charge the proposer for the storage - // used - let initial_storage = env::storage_usage(); - - let tee_upgrade_deadline_duration = - Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); - // The node always signs submissions with an Ed25519 key // (`near_signer_key`), so the signer key here is Ed25519 in practice. // Reject non-Ed25519 signer keys rather than silently storing a value @@ -792,62 +810,140 @@ impl MpcContract { } })?; - // Add the participant information to the contract state - let attestation_insertion_result = self - .tee_state - .add_participant( - NodeId { - account_id: account_id.clone(), - tls_public_key, - account_public_key, - }, - proposed_participant_attestation, - tee_upgrade_deadline_duration, + let node_id = NodeId { + account_id: account_id.clone(), + tls_public_key, + account_public_key, + }; + // Decides who pays for storage. Captured now because the async Dstack + // path checks it in a later callback, where the caller is the contract + // itself and participant status can no longer be derived. + let caller_is_participant = self.voter_account().is_ok(); + + match proposed_participant_attestation { + Attestation::Mock(mock) => { + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + let initial_storage = env::storage_usage(); + let insertion = self.tee_state.add_mock_participant( + node_id, + mock, + tee_upgrade_deadline_duration, + )?; + self.charge_attestation_storage( + &account_id, + initial_storage, + &insertion, + caller_is_participant, + env::attached_deposit(), + )?; + Ok(()) + } + Attestation::Dstack(dstack) => { + self.submit_dstack_attestation(node_id, dstack, caller_is_participant) + } + } + } + + /// Async [`Attestation::Dstack`] submission: registers a yield, fires the + /// cross-contract verify-quote call, and resumes via + /// [`Self::resolve_verification`]. + fn submit_dstack_attestation( + &mut self, + node_id: NodeId, + dstack: DstackAttestation, + caller_is_participant: bool, + ) -> Result<(), Error> { + let account_id = node_id.account_id.clone(); + + if self.pending_attestations.contains_key(&account_id) { + return Err(TeeError::VerificationAlreadyPending.into()); + } + + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; + + let attached_deposit = env::attached_deposit(); + let tls_public_key = node_id.tls_public_key.clone(); + + // Call the verifier; `resolve_verification` bridges its response back into + // the yield registered below. Scheduled before `enqueue_yield_request` so + // that helper's `promise_return` stays the method's last host call. + Promise::new(verifier_account_id) + .function_call( + method_names::VERIFY_QUOTE.to_string(), + borsh::to_vec(&(&dstack.quote, &dstack.collateral)) + .expect("borsh serialization of verify_quote args must succeed"), + NearToken::from_yoctonear(0), + Gas::from_tgas(self.config.verifier_tera_gas), ) - .map_err(|err| { - let reason = match &err { - AttestationSubmissionError::InvalidAttestation(_) => { - format!("TeeQuoteStatus is invalid: {err}") - } - AttestationSubmissionError::TlsKeyOwnedByOtherAccount => err.to_string(), - }; - InvalidParameters::InvalidTeeRemoteAttestation { reason } - })?; + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(self.config.resolve_verification_tera_gas)) + .resolve_verification(node_id), + ) + .detach(); - let caller_is_not_participant = self.voter_account().is_err(); - let is_new_attestation = matches!( - attestation_insertion_result, - ParticipantInsertion::NewlyInsertedParticipant + self.enqueue_yield_request( + method_names::ON_ATTESTATION_VERIFIED, + serde_json::to_vec(&(&account_id,)) + .expect("json serialization of account_id must succeed"), + Gas::from_tgas(self.config.on_attestation_verified_tera_gas), + |this, data_id| { + this.pending_attestations.insert( + account_id.clone(), + PendingAttestation { + dstack, + tls_public_key, + attached_deposit, + caller_is_participant, + data_id, + }, + ); + }, ); - let attestation_storage_must_be_paid_by_caller = - is_new_attestation || caller_is_not_participant; + // The yield is the method's return value: `enqueue_yield_request` called + // `promise_return` as the final host call, so returning unit here adds no + // `value_return` that would override it. + Ok(()) + } - if attestation_storage_must_be_paid_by_caller { - // `saturating_sub`: if a re-submission shrinks the entry, charge nothing - // rather than underflow. Intentional asymmetry: we do not refund freed bytes - // either — the caller already paid for the larger entry, and we'd rather - // accept that asymmetry than open a refund path for payload-shrinking games. - let storage_used = env::storage_usage().saturating_sub(initial_storage); - let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - let attached = env::attached_deposit(); + fn charge_attestation_storage( + &self, + account_id: &AccountId, + initial_storage: u64, + insertion: &ParticipantInsertion, + caller_is_participant: bool, + attached: NearToken, + ) -> Result<(), Error> { + let is_new_attestation = + matches!(insertion, ParticipantInsertion::NewlyInsertedParticipant); + // A participant refreshing an existing attestation is not charged. + if caller_is_participant && !is_new_attestation { + return Ok(()); + } - if attached < cost { - return Err(InvalidParameters::InsufficientDeposit { - attached: attached.as_yoctonear(), - required: cost.as_yoctonear(), - } - .into()); - } + // `saturating_sub`: if a re-submission shrinks the entry, charge nothing + // rather than underflow. Intentional asymmetry: we do not refund freed + // bytes either, since the caller already paid for the larger entry. + let storage_used = env::storage_usage().saturating_sub(initial_storage); + let cost = env::storage_byte_cost().saturating_mul(storage_used as u128); - // Refund the difference if the proposer attached more than required - if let Some(diff) = attached.checked_sub(cost) - && diff > NearToken::from_yoctonear(0) - { - Promise::new(account_id).transfer(diff).detach(); + if attached < cost { + return Err(InvalidParameters::InsufficientDeposit { + attached: attached.as_yoctonear(), + required: cost.as_yoctonear(), } + .into()); } + if let Some(diff) = attached.checked_sub(cost) + && diff > NearToken::from_yoctonear(0) + { + Promise::new(account_id.clone()).transfer(diff).detach(); + } Ok(()) } @@ -1969,6 +2065,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2048,6 +2145,7 @@ impl MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), }) } @@ -2271,6 +2369,143 @@ impl MpcContract { } } + /// Verify-quote callback: maps the verifier's response to an [`AttestationResult`] + /// and resumes the yield. + #[private] + pub fn resolve_verification( + &mut self, + node_id: NodeId, + #[serializer(borsh)] + #[callback_result] + result: Result, + ) { + let account_id = node_id.account_id.clone(); + + // No verdict (verifier unreachable, panicked, or out of gas). Don't resume; + // the yield timeout fires `on_attestation_verified` to clean up and refund. + let result = match result { + Ok(result) => result, + Err(promise_err) => { + log!("verifier did not answer for {account_id}: {promise_err:?}"); + return; + } + }; + + // Take the pending entry now. A late verifier response can arrive after the + // ~200-block yield timeout already fired and `on_attestation_verified` removed + // the entry and resolved the yield; there is then nothing to do. + let Some(pending) = self.pending_attestations.remove(&account_id) else { + log!( + "resolve_verification: no pending attestation for {account_id} (late response or already cleaned up); ignoring" + ); + return; + }; + + let attestation_result = match result { + VerificationResult::Rejected(reason) => { + log!("verifier rejected quote for {account_id}: {reason}"); + AttestationResult::Err(format!("verifier rejected quote: {reason}")) + } + VerificationResult::Verified(report) => { + self.finish_verified_attestation(&node_id, &pending, &report) + } + }; + + if matches!(attestation_result, AttestationResult::Err(_)) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + } + // MUST be the last host call: anything after could panic and roll back + // the state mutations above. + env::promise_yield_resume( + &pending.data_id, + serde_json::to_vec(&attestation_result) + .expect("json serialization of AttestationResult must succeed"), + ); + } + + /// Runs the post-DCAP checks and stores the attestation for a + /// [`VerificationResult::Verified`] response, returning the outcome to resume + /// the yield with. On failure it reverts the store explicitly, since the + /// callback receipt commits regardless (unlike the synchronous path). + fn finish_verified_attestation( + &mut self, + node_id: &NodeId, + pending: &PendingAttestation, + report: &VerifiedReport, + ) -> AttestationResult { + let account_id = &node_id.account_id; + let tee_upgrade_deadline_duration = + Duration::from_secs(self.config.tee_upgrade_deadline_duration_seconds); + + let initial_storage = env::storage_usage(); + let insertion = match self.tee_state.finish_dstack_verify( + node_id.clone(), + &pending.dstack, + report, + tee_upgrade_deadline_duration, + ) { + Ok(insertion) => insertion, + Err(err) => { + log!("post-DCAP check failed for {account_id}: {err}"); + return AttestationResult::Err(format!("post-DCAP check failed: {err}")); + } + }; + + match self.charge_attestation_storage( + account_id, + initial_storage, + &insertion, + pending.caller_is_participant, + pending.attached_deposit, + ) { + Ok(()) => AttestationResult::Ok, + Err(err) => { + // This receipt commits even though we resume the yield with an + // error, so the store above is NOT rolled back automatically + // (unlike the synchronous path). Undo it explicitly, or the + // caller would get storage for free plus a full refund. + self.tee_state + .revert_dstack_store(&pending.tls_public_key, insertion); + AttestationResult::Err(err.to_string()) + } + } + } + + /// Yield-resume callback for a [`Attestation::Dstack`] submission. On + /// success it resolves the caller's transaction; on a rejection or the + /// ~200-block timeout it cleans up, refunds, and fails from a separate + /// receipt. + #[private] + pub fn on_attestation_verified( + &mut self, + account_id: AccountId, + #[callback_result] result: Result, + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(AttestationResult::Ok) => return PromiseOrValue::Value(()), + Ok(AttestationResult::Err(reason)) => reason, + Err(_promise_err) => { + // Timeout: the resolution callback never resumed us, so the + // pending entry is still here. Clean it up and refund. + if let Some(pending) = self.pending_attestations.remove(&account_id) { + refund_attestation_deposit(&account_id, pending.attached_deposit); + log!("yield timeout for {account_id}: refunded and cleaned up"); + } + "verifier did not respond within the yield-resume window".to_string() + } + }; + + // Fail the submitter's transaction from a separate receipt so the + // cleanup above commits (a panic here would roll it back). + let promise = Promise::new(env::current_account_id()).function_call( + method_names::FAIL_ATTESTATION_SUBMISSION.to_string(), + borsh::to_vec(&reason).expect("borsh serialization of reason must succeed"), + NearToken::from_near(0), + Gas::from_tgas(self.config.fail_attestation_submission_tera_gas), + ); + PromiseOrValue::Promise(promise.as_return()) + } + /// Yield-resume callback for a single queued CKD request. /// /// On success, returns the confidential key to the original caller. On timeout, @@ -2337,6 +2572,11 @@ impl MpcContract { } } + #[private] + pub fn fail_attestation_submission(#[serializer(borsh)] reason: String) { + env::panic_str(&reason); + } + #[private] pub fn fail_on_timeout() { // To stay consistent with the old version of the timeout error @@ -2701,9 +2941,8 @@ mod tests { use elliptic_curve::Group; use k256::{self, Secp256k1, ecdsa::SigningKey, elliptic_curve}; use mpc_attestation::attestation::{ - Attestation as MpcAttestation, MockAttestation as MpcMockAttestation, VerifiedAttestation, + MockAttestation as MpcMockAttestation, VerifiedAttestation, }; - use mpc_primitives::hash::DockerImageHash; use near_mpc_bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use near_mpc_contract_interface::types::BackupServiceInfo; use near_mpc_contract_interface::types::CKDAppPublicKey; @@ -2721,10 +2960,6 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; - use test_utils::attestation::{ - VALID_ATTESTATION_TIMESTAMP, image_digest, launcher_image_hash, - mock_dto_dstack_attestation, near_account_key, p2p_tls_key, - }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; @@ -4109,8 +4344,8 @@ mod tests { if let Err(error) = result { let error_string = error.to_string(); assert!( - error_string.contains("TeeQuoteStatus is invalid"), - "Error should mention invalid TEE status, got: {}", + error_string.contains("failed verification"), + "Error should mention attestation verification failure, got: {}", error_string ); } @@ -4585,6 +4820,7 @@ mod tests { ), tee_verifier_account_id: None, tee_verifier_votes: Default::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } @@ -5036,14 +5272,12 @@ mod tests { destination_node_info, ); } - let valid_participant_attestation = mpc_attestation::attestation::Attestation::Mock( - mpc_attestation::attestation::MockAttestation::Valid, - ); + let valid_participant_attestation = MpcMockAttestation::Valid; let tee_upgrade_duration = Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds); - let insertion_result = contract.tee_state.add_participant( + let insertion_result = contract.tee_state.add_mock_participant( NodeId { account_id: self.signer_account_id.clone(), tls_public_key: self.attestation_tls_key.clone(), @@ -5697,15 +5931,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, - }); + }; contract .tee_state - .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .add_mock_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); // Capture the running state before verify_tee for comparison @@ -5816,15 +6050,15 @@ mod tests { tls_public_key: target_participant_info.tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let expiring_attestation = MpcAttestation::Mock(MpcMockAttestation::WithConstraints { + let expiring_attestation = MpcMockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(ATTESTATION_EXPIRY_SECONDS), expected_measurements: None, - }); + }; contract .tee_state - .add_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) + .add_mock_participant(node_id, expiring_attestation, TEE_UPGRADE_DURATION) .expect("mock attestation is not yet expired and valid"); let (first_account_id, _, _) = &participant_list[0]; @@ -5847,247 +6081,6 @@ mod tests { assert!(!contract.accept_requests); } - /// Sets up a complete TEE test environment with contract, accounts, mock dstack attestation, TLS key and the node's near public key. - /// This is a helper function that provides all the common components needed for TEE-related tests. - fn setup_tee_test() -> ( - MpcContract, - Vec, - Attestation, - dtos::Ed25519PublicKey, - DockerImageHash, - near_sdk::PublicKey, - ) { - let (_context, contract, _secret_key) = basic_setup(Curve::Bls12381, &mut OsRng); - - let participant_account_ids: Vec<_> = contract - .protocol_state - .threshold_parameters() - .unwrap() - .participants() - .participants() - .iter() - .map(|(account_id, _, _)| account_id.clone()) - .collect(); - - let attestation = mock_dto_dstack_attestation(); - let tls_key = p2p_tls_key().into(); - let mpc_hash = image_digest(); - let near_public_key = near_account_key(); - - ( - contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) - } - - /// Sets up a contract with an approved MPC hash by having the participants vote for it. - /// Also adds the legacy launcher image hash so that compose hashes are derived correctly. - /// This is a helper function commonly used in tests that require pre-approved hashes. - fn setup_approved_mpc_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - mpc_hash: &DockerImageHash, - block_timestamp_ns: u64, - ) { - // Add the legacy launcher image first, so that compose hashes are derived - // when the MPC hash is voted in. - setup_approved_launcher_hash(contract, participant_account_ids, block_timestamp_ns); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract.vote_code_hash(*mpc_hash).expect("vote succeeds"); - } - } - - /// Adds the launcher image hash from test attestation assets. - /// The hash is extracted from `test-utils/assets/launcher_image_compose.yaml`. - fn setup_approved_launcher_hash( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - let launcher_hash = launcher_image_hash(); - - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_launcher_hash(launcher_hash) - .expect("launcher vote succeeds"); - } - } - - /// Adds the default OS measurements so that Dstack attestation verification passes. - fn setup_approved_measurements( - contract: &mut MpcContract, - participant_account_ids: &[near_sdk::AccountId], - block_timestamp_ns: u64, - ) { - for measurement in mpc_attestation::attestation::default_measurements() { - let contract_measurement = ContractExpectedMeasurements::from(*measurement); - for participant_account_id in participant_account_ids { - testing_env!( - VMContextBuilder::new() - .signer_account_id(participant_account_id.clone()) - .predecessor_account_id(participant_account_id.clone()) - .block_timestamp(block_timestamp_ns) - .build() - ); - - contract - .vote_add_os_measurement(contract_measurement.clone()) - .expect("measurement vote succeeds"); - } - } - } - - /// **Test method with matching measurements** - Tests that participant info submission succeeds with the test-only method. - /// Unlike the test above, this one has an approved MPC hash. It uses the test method with custom measurements that match - /// the attestation data. - #[test] - fn test_submit_participant_info_succeeds_with_valid_dstack_attestation() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - assert_matches::assert_matches!(result, Ok(())); - } - - /// Note - this test uses attestation data from a real MPC node. After Any change to the expected contract measurement, /test-utils/assets need to be updated. - /// see crates/test-utils/assets/README.md for details. - /// **No MPC hash approval** - Tests that participant info submission fails when no MPC hash has been approved yet. - /// This verifies the prerequisite step: the contract requires MPC hash approval before accepting any participant TEE information. - #[test] - fn test_submit_participant_info_fails_without_approved_mpc_hash() { - // given - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - _mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - let result = contract.submit_participant_info(attestation, tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: Custom(\"the allowed mpc image hashes list is empty\")"), "Got error: {}", &error_string); - } - - /// **TLS key validation** - Tests that TEE attestation fails when TLS key doesn't match the one in report data. - /// Similar to the successful test method case above, but uses a deliberately corrupted TLS key to verify - /// that attestation validation properly checks the TLS key embedded in the attestation report. - #[test] - fn test_tee_attestation_fails_with_invalid_tls_key() { - let ( - mut contract, - participant_account_ids, - attestation, - tls_key, - mpc_hash, - near_public_key, - ) = setup_tee_test(); - - let block_timestamp_ns = VALID_ATTESTATION_TIMESTAMP * 1_000_000_000; - - // when - setup_approved_mpc_hash( - &mut contract, - &participant_account_ids, - &mpc_hash, - block_timestamp_ns, - ); - setup_approved_measurements(&mut contract, &participant_account_ids, block_timestamp_ns); - - // Create invalid TLS key by flipping the last bit - let mut invalid_tls_key_bytes = *tls_key.as_bytes(); - let last_byte_idx = invalid_tls_key_bytes.len() - 1; - invalid_tls_key_bytes[last_byte_idx] ^= 0x01; - let invalid_tls_key = Ed25519PublicKey::from(invalid_tls_key_bytes); - - let account_id = participant_account_ids[0].clone(); - testing_env!( - VMContextBuilder::new() - .signer_account_id(account_id.clone()) - .predecessor_account_id(account_id.clone()) - .signer_account_pk(near_public_key.clone()) - .attached_deposit(NearToken::from_near(1)) - .block_timestamp(block_timestamp_ns) - .build() - ); - - let result = contract.submit_participant_info(attestation, invalid_tls_key); - - // then - let error_string = result.unwrap_err().to_string(); - assert!(error_string - .contains("Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: the submitted attestation failed verification, reason: WrongHash { name: \"report_data\""), "Got error: {}", &error_string); - } - fn make_launcher_hash(byte: u8) -> LauncherImageHash { LauncherImageHash::from([byte; 32]) } @@ -7504,13 +7497,13 @@ mod tests { // Add attestation for the new node (mirrors what ConcludeNodeMigrationTestSetup::setup does). contract .tee_state - .add_participant( + .add_mock_participant( NodeId { account_id: operator4.clone(), tls_public_key: new_tls_key.clone(), account_public_key: new_signer_pk.clone(), }, - mpc_attestation::attestation::Attestation::Mock(MpcMockAttestation::Valid), + MpcMockAttestation::Valid, Duration::from_secs(contract.config.tee_upgrade_deadline_duration_seconds), ) .expect("attestation insertion should succeed"); diff --git a/crates/contract/src/sandbox_test_methods.rs b/crates/contract/src/sandbox_test_methods.rs index 28997cd7af..63120a02ad 100644 --- a/crates/contract/src/sandbox_test_methods.rs +++ b/crates/contract/src/sandbox_test_methods.rs @@ -11,7 +11,7 @@ use crate::MpcContract; use crate::primitives::ckd::CKDRequest; use crate::primitives::signature::SignatureRequest; -use near_sdk::near; +use near_sdk::{AccountId, near}; // Import the generated extension trait from near use crate::MpcContractExt; @@ -48,4 +48,8 @@ impl MpcContract { u32::try_from(len) .expect("queue length must fit in u32 — bounded by MAX_PENDING_REQUEST_FAN_OUT") } + + pub fn has_pending_attestation(&self, account_id: AccountId) -> bool { + self.pending_attestations.contains_key(&account_id) + } } 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 8f1b147a31..8247fb153b 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 @@ -234,6 +234,10 @@ BorshSchemaContainer { "fail_on_timeout_tera_gas", "u64", ), + ( + "fail_attestation_submission_tera_gas", + "u64", + ), ( "clean_tee_status_tera_gas", "u64", @@ -258,6 +262,18 @@ BorshSchemaContainer { "remove_non_participant_tee_verifier_votes_tera_gas", "u64", ), + ( + "verifier_tera_gas", + "u64", + ), + ( + "resolve_verification_tera_gas", + "u64", + ), + ( + "on_attestation_verified_tera_gas", + "u64", + ), ], ), }, @@ -711,6 +727,10 @@ BorshSchemaContainer { "tee_verifier_votes", "TeeVerifierVotes", ), + ( + "pending_attestations", + "LookupMap", + ), ], ), }, diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index e4e0349c6d..1bb1dc88e8 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -34,4 +34,5 @@ pub enum StorageKey { ForeignChainMetadata, TeeVerifierVotesByVoter, TeeVerifierVotesByProposal, + PendingAttestations, } diff --git a/crates/contract/src/tee.rs b/crates/contract/src/tee.rs index 9fafd439f9..310f91b910 100644 --- a/crates/contract/src/tee.rs +++ b/crates/contract/src/tee.rs @@ -1,4 +1,5 @@ pub mod measurements; +pub mod pending_attestation; pub mod proposal; pub mod tee_state; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/contract/src/tee/pending_attestation.rs b/crates/contract/src/tee/pending_attestation.rs new file mode 100644 index 0000000000..ce9fc5cc57 --- /dev/null +++ b/crates/contract/src/tee/pending_attestation.rs @@ -0,0 +1,53 @@ +//! State for an in-flight [`DstackAttestation`] submission. +//! +//! A [`DstackAttestation`] submission is asynchronous: it yields, fires a cross-contract +//! verify-quote call, and resumes from the response callback. What the callback +//! needs but cannot re-read from contract state is stashed here, keyed by the +//! submitter's account id, until the yield resolves. + +use borsh::{BorshDeserialize, BorshSerialize}; +use mpc_attestation::attestation::DstackAttestation; +use near_mpc_contract_interface::types::Ed25519PublicKey; +use near_sdk::{CryptoHash, NearToken, near}; + +/// One in-flight verification per submitter account. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct PendingAttestation { + /// The submitted payload the post-DCAP checks consume once the verifier + /// returns its report. + pub dstack: DstackAttestation, + /// Checked against the quote's report-data during the post-DCAP checks. + pub tls_public_key: Ed25519PublicKey, + /// Stashed because the deposit is not visible from the callback receipt: + /// consumed for storage on success, refunded on failure. + pub attached_deposit: NearToken, + /// Participant status at submit time, which decides whether the caller pays + /// for storage. Captured because the callback receipt is no longer the + /// caller, so it can no longer be re-derived. + pub caller_is_participant: bool, + /// Yield handle, read back by the callback to resume the yield. + pub data_id: CryptoHash, +} + +#[near(serializers = [json])] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationResult { + Ok, + Err(String), +} + +#[cfg(test)] +#[expect(non_snake_case)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(AttestationResult::Ok)] + #[case(AttestationResult::Err("rejected".to_string()))] + fn attestation_result__should_round_trip_json(#[case] original: AttestationResult) { + let bytes = serde_json::to_vec(&original).expect("serialize"); + let decoded: AttestationResult = serde_json::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); + } +} diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index 6d74a8f0e7..13dba9d5c2 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -11,13 +11,17 @@ use crate::{ }; use borsh::{BorshDeserialize, BorshSerialize}; use mpc_attestation::{ - attestation::{self, AcceptedAttestation, Attestation, VerifiedAttestation}, + attestation::{ + self, AcceptedAttestation, DstackAttestation, DstackVerify, MockAttestation, + VerifiedAttestation, + }, report_data::{ReportData, ReportDataV1}, }; use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash}; use near_mpc_contract_interface::types::Ed25519PublicKey; use near_sdk::{env, near, store::IterableMap}; use std::time::Duration; +use tee_verifier_interface::VerifiedReport; pub use near_mpc_contract_interface::types::NodeId; @@ -33,8 +37,8 @@ pub enum TeeQuoteStatus { Invalid(String), } -#[derive(Debug, Clone, thiserror::Error)] -pub(crate) enum AttestationSubmissionError { +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AttestationSubmissionError { #[error("the submitted attestation failed verification, reason: {:?}", .0)] InvalidAttestation(#[from] attestation::VerificationError), #[error( @@ -44,9 +48,12 @@ pub(crate) enum AttestationSubmissionError { } #[derive(Debug)] +#[expect(clippy::large_enum_variant)] pub(crate) enum ParticipantInsertion { NewlyInsertedParticipant, - UpdatedExistingParticipant, + /// Holds the overwritten entry so [`TeeState::revert_dstack_store`] can put + /// it back if the async store is rolled back. + UpdatedExistingParticipant(NodeAttestation), } #[derive(Debug)] @@ -143,31 +150,48 @@ impl TeeState { } fn current_time_seconds() -> u64 { - let current_time_milliseconds = env::block_timestamp_ms(); - current_time_milliseconds / 1_000 + env::block_timestamp_ms() / 1_000 } - /// Adds a participant attestation for the given node iff the attestation succeeds verification. - pub(crate) fn add_participant( + pub(crate) fn add_mock_participant( &mut self, node_id: NodeId, - attestation: Attestation, + mock: MockAttestation, tee_upgrade_deadline_duration: Duration, ) -> Result { - let expected_report_data: ReportData = ReportDataV1::new( - *node_id.tls_public_key.as_bytes(), - *node_id.account_public_key.as_bytes(), - ) - .into(); + let AcceptedAttestation { + attestation: verified_attestation, + advisory_ids, + } = mock.verify( + Self::current_time_seconds(), + &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), + &self.get_allowed_launcher_compose_hashes(), + &self.get_accepted_measurements(), + )?; + + log_informational_advisory_ids(&advisory_ids); + + self.store_verified_attestation(node_id, verified_attestation) + } + /// Runs the post-DCAP checks for a [`Attestation::Dstack`] attestation + /// against the [`VerifiedReport`] the verifier returned, then stores the + /// result. + pub(crate) fn finish_dstack_verify( + &mut self, + node_id: NodeId, + dstack: &DstackAttestation, + report: &VerifiedReport, + tee_upgrade_deadline_duration: Duration, + ) -> Result { + let expected_report_data = Self::expected_report_data(&node_id); let accepted_measurements = self.get_accepted_measurements(); - // TODO(#3264): run DCAP in the verifier contract (Promise + callback) and - // do the post-DCAP checks here, instead of verifying locally in-WASM. let AcceptedAttestation { attestation: verified_attestation, advisory_ids, - } = attestation.verify_locally( - expected_report_data.into(), + } = dstack.verify( + report, + expected_report_data, Self::current_time_seconds(), &self.get_allowed_mpc_docker_image_hashes(tee_upgrade_deadline_duration), &self.get_allowed_launcher_compose_hashes(), @@ -175,7 +199,30 @@ impl TeeState { )?; log_informational_advisory_ids(&advisory_ids); + self.store_verified_attestation(node_id, verified_attestation) + } + + fn expected_report_data(node_id: &NodeId) -> ::attestation::report_data::ReportData { + let report_data: ReportData = ReportDataV1::new( + *node_id.tls_public_key.as_bytes(), + *node_id.account_public_key.as_bytes(), + ) + .into(); + report_data.into() + } + /// Stores an already-verified attestation, rejecting a TLS key owned by a + /// different account. + /// + /// On an update, the returned [`ParticipantInsertion::UpdatedExistingParticipant`] + /// carries the displaced [`NodeAttestation`]; the Dstack path uses it to undo + /// this store via [`Self::revert_dstack_store`], because its callback receipt + /// commits even when the later storage charge fails. + fn store_verified_attestation( + &mut self, + node_id: NodeId, + verified_attestation: VerifiedAttestation, + ) -> Result { let tls_pk = node_id.tls_public_key.clone(); // Authorization: a TLS key registered to one account must not be @@ -188,7 +235,7 @@ impl TeeState { return Err(AttestationSubmissionError::TlsKeyOwnedByOtherAccount); } - let insertion = self.stored_attestations.insert( + let previous = self.stored_attestations.insert( tls_pk, NodeAttestation { node_id, @@ -196,12 +243,32 @@ impl TeeState { }, ); - Ok(match insertion { - Some(_previous_attestation) => ParticipantInsertion::UpdatedExistingParticipant, + Ok(match previous { + Some(previous) => ParticipantInsertion::UpdatedExistingParticipant(previous), None => ParticipantInsertion::NewlyInsertedParticipant, }) } + /// Undoes a [`Self::finish_dstack_verify`] store: restores the displaced + /// entry, or removes the newly-inserted one if there was none. Used by the + /// async flow when the storage charge fails after the store, so a caller + /// can't get storage for free in a receipt that still commits. + pub(crate) fn revert_dstack_store( + &mut self, + tls_public_key: &Ed25519PublicKey, + insertion: ParticipantInsertion, + ) { + match insertion { + ParticipantInsertion::UpdatedExistingParticipant(previous) => { + self.stored_attestations + .insert(tls_public_key.clone(), previous); + } + ParticipantInsertion::NewlyInsertedParticipant => { + self.stored_attestations.remove(tls_public_key); + } + } + } + /// reverifies stored participant attestations. pub(crate) fn reverify_participants( &self, @@ -541,7 +608,7 @@ mod tests { }; use crate::tee::test_utils::set_block_timestamp; use assert_matches::assert_matches; - use mpc_attestation::attestation::{Attestation, MockAttestation}; + use mpc_attestation::attestation::MockAttestation; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; use near_sdk::test_utils::VMContextBuilder; @@ -580,7 +647,7 @@ mod tests { .collect(); // Add TEE information for all participants and non-participant - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; let non_participant_uid = NodeId { account_id: non_participant.clone(), @@ -590,7 +657,7 @@ mod tests { for node_id in &participant_nodes { tee_state - .add_participant( + .add_mock_participant( node_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -598,7 +665,7 @@ mod tests { .unwrap(); } tee_state - .add_participant( + .add_mock_participant( non_participant_uid.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -649,24 +716,24 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; - let fresh = Attestation::Mock(MockAttestation::WithConstraints { + let fresh = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(FRESH_EXPIRY_SECONDS), expected_measurements: None, - }); - let stale = Attestation::Mock(MockAttestation::WithConstraints { + }; + let stale = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(STALE_EXPIRY_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) + .add_mock_participant(fresh_node.clone(), fresh, Duration::from_secs(0)) .unwrap(); tee_state - .add_participant(stale_node.clone(), stale, Duration::from_secs(0)) + .add_mock_participant(stale_node.clone(), stale, Duration::from_secs(0)) .unwrap(); assert_eq!(tee_state.stored_attestations.len(), 2); @@ -699,12 +766,12 @@ mod tests { let mut tee_state = TeeState::default(); - let expired = Attestation::Mock(MockAttestation::WithConstraints { + let expired = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_SECONDS), expected_measurements: None, - }); + }; for idx in 0..10 { let node_id = NodeId { @@ -713,7 +780,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant(node_id, expired.clone(), Duration::from_secs(0)) + .add_mock_participant(node_id, expired.clone(), Duration::from_secs(0)) .unwrap(); } assert_eq!(tee_state.stored_attestations.len(), 10); @@ -749,14 +816,14 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(FUTURE_EXPIRY_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // When: cleanup runs while the attestation is still valid. @@ -778,7 +845,7 @@ mod tests { let mut tee_state = TeeState::default(); let participant: AccountId = "dave.near".parse().unwrap(); - let local_attestation = Attestation::Mock(MockAttestation::Valid); + let local_attestation = MockAttestation::Valid; let participant_id = NodeId { account_id: participant.clone(), @@ -786,7 +853,7 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), }; - let insertion_result = tee_state.add_participant( + let insertion_result = tee_state.add_mock_participant( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -797,7 +864,7 @@ mod tests { ); // when - let re_insertion_result = tee_state.add_participant( + let re_insertion_result = tee_state.add_mock_participant( participant_id.clone(), local_attestation.clone(), TEE_UPGRADE_DURATION, @@ -806,7 +873,7 @@ mod tests { // then assert_matches!( re_insertion_result, - Ok(ParticipantInsertion::UpdatedExistingParticipant) + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) ); } @@ -819,11 +886,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id, attestation, Duration::from_secs(0)) + .add_mock_participant(node_id, attestation, Duration::from_secs(0)) .unwrap(); // then @@ -843,11 +910,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -868,11 +935,11 @@ mod tests { tls_public_key: bogus_ed25519_public_key(), account_public_key: bogus_ed25519_public_key(), }; - let attestation = Attestation::Mock(MockAttestation::Valid); + let attestation = MockAttestation::Valid; // when tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // then @@ -906,16 +973,16 @@ mod tests { // when tee_state - .add_participant( + .add_mock_participant( node_1.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); tee_state - .add_participant( + .add_mock_participant( node_2.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, Duration::from_secs(0), ) .unwrap(); @@ -948,15 +1015,15 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(NOW_SECONDS).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(NOW_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -981,15 +1048,15 @@ mod tests { testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1026,15 +1093,15 @@ mod tests { .build() ); - let attestation = Attestation::Mock(MockAttestation::WithConstraints { + let attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(EXPIRY_TIMESTAMP_SECONDS), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id.clone(), attestation, Duration::from_secs(0)) + .add_mock_participant(node_id.clone(), attestation, Duration::from_secs(0)) .unwrap(); // when @@ -1087,11 +1154,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // 4. Verify check passes @@ -1152,11 +1215,7 @@ mod tests { account_public_key: Ed25519PublicKey::try_from(&signer_pk).unwrap(), }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); let result = tee_state.is_caller_an_attested_participant(&participants); @@ -1188,11 +1247,7 @@ mod tests { account_public_key: old_signer_pk, // Mismatch here }; tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("Attestation is valid on insertion"); // when @@ -1235,11 +1290,7 @@ mod tests { for (account_id, _, participant_info) in participants.participants().iter() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1260,11 +1311,7 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Third participant has no attestation @@ -1294,25 +1341,21 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Add expiring attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let expiring_attestation = Attestation::Mock(MockAttestation::WithConstraints { + let expiring_attestation = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_time_secs), expected_measurements: None, - }); + }; tee_state - .add_participant(node_id, expiring_attestation, tee_upgrade_duration) + .add_mock_participant(node_id, expiring_attestation, tee_upgrade_duration) .expect("mock attestation is valid"); // Advance time to exact expiry boundary @@ -1345,17 +1388,17 @@ mod tests { for (i, (account_id, _, participant_info)) in participant_list.iter().enumerate() { let node_id = create_node_id(account_id, &participant_info.tls_public_key); let attestation = if i == 2 { - Attestation::Mock(MockAttestation::WithConstraints { + MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(expiry_time_secs), expected_measurements: None, - }) + } } else { - Attestation::Mock(MockAttestation::Valid) + MockAttestation::Valid }; tee_state - .add_participant(node_id, attestation, tee_upgrade_duration) + .add_mock_participant(node_id, attestation, tee_upgrade_duration) .expect("mock attestation is valid"); } @@ -1386,9 +1429,9 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant( + .add_mock_participant( alice_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ) .expect("initial insertion should succeed"); @@ -1399,9 +1442,9 @@ mod tests { tls_public_key: tls_public_key.clone(), account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_participant( + let result = tee_state.add_mock_participant( attacker_node, - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); @@ -1431,11 +1474,7 @@ mod tests { account_public_key: bogus_ed25519_public_key(), }; tee_state - .add_participant( - initial_node, - Attestation::Mock(MockAttestation::Valid), - TEE_UPGRADE_DURATION, - ) + .add_mock_participant(initial_node, MockAttestation::Valid, TEE_UPGRADE_DURATION) .expect("initial insertion should succeed"); // When: the same account resubmits with a rotated account_public_key. @@ -1444,14 +1483,17 @@ mod tests { tls_public_key, account_public_key: bogus_ed25519_public_key(), }; - let result = tee_state.add_participant( + let result = tee_state.add_mock_participant( rotated_node.clone(), - Attestation::Mock(MockAttestation::Valid), + MockAttestation::Valid, TEE_UPGRADE_DURATION, ); // Then: the update is accepted and the stored entry reflects the new key. - assert_matches!(result, Ok(ParticipantInsertion::UpdatedExistingParticipant)); + assert_matches!( + result, + Ok(ParticipantInsertion::UpdatedExistingParticipant(_)) + ); let stored = tee_state .stored_attestations .get(&rotated_node.tls_public_key) @@ -1470,22 +1512,15 @@ mod tests { for (account_id, _, participant_info) in participant_list.iter().take(2) { let node_id = create_node_id(account_id, &participant_info.tls_public_key); tee_state - .add_participant( - node_id, - Attestation::Mock(MockAttestation::Valid), - tee_upgrade_duration, - ) + .add_mock_participant(node_id, MockAttestation::Valid, tee_upgrade_duration) .expect("mock attestation is valid"); } // Add invalid attestation for third participant let (account_id, _, participant_info) = &participant_list[2]; let node_id = create_node_id(account_id, &participant_info.tls_public_key); - let add_participant_result = tee_state.add_participant( - node_id, - Attestation::Mock(MockAttestation::Invalid), - tee_upgrade_duration, - ); + let add_participant_result = + tee_state.add_mock_participant(node_id, MockAttestation::Invalid, tee_upgrade_duration); assert_matches!( add_participant_result, diff --git a/crates/contract/src/v3_12_0_state.rs b/crates/contract/src/v3_12_0_state.rs index cfa9567bd5..48bccc24a2 100644 --- a/crates/contract/src/v3_12_0_state.rs +++ b/crates/contract/src/v3_12_0_state.rs @@ -125,6 +125,7 @@ impl From for crate::MpcContract { ), tee_verifier_account_id: None, tee_verifier_votes: TeeVerifierVotes::default(), + pending_attestations: LookupMap::new(StorageKey::PendingAttestations), } } } diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 937611a957..42f88998aa 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,14 +3,14 @@ use mpc_contract::{ MpcContract, crypto_shared::types::PublicKeyExtended, - errors::{Error, InvalidParameters}, + errors::Error, primitives::{ key_state::{AttemptId, EpochId, KeyForDomain, Keyset}, participants::{ParticipantId, ParticipantInfo}, test_utils::{bogus_ed25519_public_key, gen_participants}, thresholds::{ProposedThresholdParameters, Threshold, ThresholdParameters}, }, - tee::tee_state::NodeId, + tee::tee_state::{AttestationSubmissionError, NodeId}, }; use near_mpc_contract_interface::types::{ Attestation, InitConfig, MockAttestation, Protocol, ProtocolContractState, @@ -341,8 +341,9 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { // entry is unchanged. assert_matches!( &attack_result, - Err(Error::InvalidParameters(InvalidParameters::InvalidTeeRemoteAttestation { reason })) - if reason.contains("TLS public key is already registered") + Err(Error::AttestationSubmission( + AttestationSubmissionError::TlsKeyOwnedByOtherAccount + )) ); let stored_after = setup .contract diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 83e370a2f0..bcf4419223 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -97,12 +97,16 @@ async fn contract_configuration_can_be_set_on_initialization() { return_signature_and_clean_state_on_success_call_tera_gas: Some(66), return_ck_and_clean_state_on_success_call_tera_gas: Some(77), fail_on_timeout_tera_gas: Some(88), + fail_attestation_submission_tera_gas: Some(89), clean_tee_status_tera_gas: Some(99), clean_invalid_attestations_tera_gas: Some(101), 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), + verifier_tera_gas: Some(15), + resolve_verification_tera_gas: Some(16), + on_attestation_verified_tera_gas: Some(17), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/mod.rs b/crates/contract/tests/sandbox/mod.rs index c4e4cbaf3d..e0f25b397a 100644 --- a/crates/contract/tests/sandbox/mod.rs +++ b/crates/contract/tests/sandbox/mod.rs @@ -6,6 +6,7 @@ pub mod participants_gas; pub mod sign; pub mod tee; pub mod tee_cleanup_after_resharing; +pub mod tee_verifier; pub mod update_votes_cleanup_after_resharing; pub mod upgrade_from_current_contract; pub mod upgrade_to_current_contract; diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..511fd0a23d --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,228 @@ +//! Sandbox tests for the async `submit_participant_info` flow that offloads DCAP +//! verification to a separate `tee-verifier` contract. +//! +//! These deploy the `test-tee-verifier` stub (which returns a test-chosen +//! `verify_quote` answer instead of running real `dcap-qvl`) and point +//! `mpc-contract` at it via `vote_tee_verifier_change`, then exercise each +//! resolution branch of the yield-resume flow: +//! +//! - verifier not configured → submission rejected, nothing stored. +//! - `Rejected` → submission fails, deposit refunded, no stored attestation. +//! - no-verdict (stub panics) → the ~200-block yield timeout cleans up. +#![allow(non_snake_case)] + +use crate::sandbox::{ + common::SandboxTestSetup, + utils::{ + consts::ALL_PROTOCOLS, + contract_build::stub_tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, has_pending_attestation, submit_participant_info, + submit_participant_info_with_deposit, vote_tee_verifier_change, + }, + }, +}; +use anyhow::Result; +use borsh::BorshSerialize; +use near_mpc_contract_interface::types::{self as dtos, Attestation}; +use near_workspaces::{Account, Contract, Worker, network::Sandbox, types::NearToken}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; + +/// Blocks to fast-forward past the ~200-block yield-resume timeout so the +/// runtime fires `on_attestation_verified`'s timeout branch. +const YIELD_TIMEOUT_BLOCKS: u64 = 250; + +/// Mirror of `test_tee_verifier::StubResponse`. Re-declared here (rather than +/// depending on the stub crate) so the test only needs its Borsh encoding to +/// initialize the deployed stub; the stub is a separate `#[near]` contract and +/// linking its crate into this test binary would collide on ABI symbols. +#[expect(clippy::large_enum_variant)] +#[derive(BorshSerialize)] +enum StubResponse { + #[expect(dead_code)] + Verified(tee_verifier_interface::VerifiedReport), + Rejected(String), + Panic, +} + +/// Deploys the stub verifier with the given response, initializes it, and votes +/// it in as `mpc-contract`'s trusted verifier (all participants vote so the +/// change crosses threshold). +async fn deploy_and_trust_stub( + worker: &Worker, + contract: &Contract, + participants: &[Account], + response: StubResponse, +) -> Result { + let stub = worker.dev_deploy(stub_tee_verifier_contract()).await?; + stub.call("new") + .args_borsh(response) + .transact() + .await? + .into_result()?; + + // The contract only consumes `candidate_account_id`; the hash is a voter + // commitment, so any agreed value works for the test. + let expected_code_hash = [7u8; 32]; + for account in participants { + vote_tee_verifier_change(account, contract, stub.id(), expected_code_hash).await?; + } + Ok(stub) +} + +fn dstack_attestation() -> Attestation { + mock_dto_dstack_attestation() +} + +fn tls_key() -> dtos::Ed25519PublicKey { + p2p_tls_key().into() +} + +#[tokio::test] +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() -> Result<()> +{ + // Given: a running contract with no verifier voted in. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await; + + // When: a participant submits a Dstack attestation. + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &dstack_attestation(), + &tls_key(), + ) + .await?; + + // Then: it is rejected (no verifier configured) and nothing is stored. + assert!( + result.is_failure(), + "Dstack submit must fail when no verifier is configured: {result:#?}" + ); + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "no attestation should be stored"); + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() +-> Result<()> { + // Given: a contract whose trusted verifier always rejects. + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Rejected("test rejection".to_string()), + ) + .await?; + + // When: a participant submits a Dstack attestation with a 1 NEAR deposit. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + + // Then: nothing is stored, the pending entry is cleaned up, and the deposit + // is refunded. The rejection resolves in the verifier's response receipt (a + // later receipt than the original call), so the outcome is observable in + // state rather than on the original transaction's result. + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!(stored.is_none(), "a rejected quote must not be stored"); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up on rejection" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +#[tokio::test] +async fn submit_participant_info__should_clean_up_on_verifier_crash() -> Result<()> { + // Given: a contract whose trusted verifier panics (no verdict). + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_sandbox_test_methods() + .build() + .await; + deploy_and_trust_stub( + &worker, + &contract, + &mpc_signer_accounts, + StubResponse::Panic, + ) + .await?; + + // When: a participant submits, the verifier crashes (no resume lands), and + // the chain advances past the ~200-block yield timeout so the runtime fires + // `on_attestation_verified`'s timeout branch. + let submitter = &mpc_signer_accounts[0]; + let balance_before = submitter.view_account().await?.balance; + // Unlike the rejection test, the outer-tx result isn't asserted here: the + // failure only resolves when the yield times out, which `near-workspaces` + // does not surface on the original `transact()`, so we assert state instead. + let _ = submit_participant_info_with_deposit( + submitter, + &contract, + &dstack_attestation(), + &tls_key(), + NearToken::from_near(1), + ) + .await?; + worker.fast_forward(YIELD_TIMEOUT_BLOCKS).await?; + + // Then: nothing is stored, and the timeout cleanup actually committed: the + // pending entry is gone and the deposit refunded. (Guards the regression + // where the cleanup was rolled back by a panic in the same receipt, leaking + // the entry and locking the account out of resubmitting.) + let stored = get_participant_attestation(&contract, &tls_key()).await?; + assert!( + stored.is_none(), + "nothing should be stored when the verifier crashes" + ); + assert!( + !has_pending_attestation(&contract, submitter.id()).await?, + "the pending entry must be cleaned up after the yield timeout" + ); + assert_deposit_refunded(submitter, balance_before).await?; + Ok(()) +} + +/// Asserts the 1 NEAR storage deposit was returned: the net spend since +/// `balance_before` is well under 1 NEAR (only gas), rather than the full +/// deposit being retained by the contract. +async fn assert_deposit_refunded(account: &Account, balance_before: NearToken) -> Result<()> { + let balance_after = account.view_account().await?.balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert!( + net_spent < NearToken::from_near(1), + "deposit should be refunded (net spent {net_spent} should be < 1 NEAR, gas only)" + ); + Ok(()) +} diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index ef9d4e712b..eef1390cc2 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -113,12 +113,16 @@ async fn test_propose_update_config() { return_signature_and_clean_state_on_success_call_tera_gas: 66, return_ck_and_clean_state_on_success_call_tera_gas: 77, fail_on_timeout_tera_gas: 88, + fail_attestation_submission_tera_gas: 89, clean_tee_status_tera_gas: 99, clean_invalid_attestations_tera_gas: 101, 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, + verifier_tera_gas: 15, + resolve_verification_tera_gas: 16, + on_attestation_verified_tera_gas: 17, }; let mut proposals = Vec::with_capacity(mpc_signer_accounts.len()); diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..1f9361694e 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -4,6 +4,7 @@ use test_utils::contract_build::ContractBuilder; const MPC_CONTRACT_MANIFEST: &str = "crates/contract/Cargo.toml"; const MIGRATION_CONTRACT_MANIFEST: &str = "crates/test-migration-contract/Cargo.toml"; const PARALLEL_CONTRACT_MANIFEST: &str = "crates/test-parallel-contract/Cargo.toml"; +const STUB_TEE_VERIFIER_MANIFEST: &str = "crates/test-tee-verifier/Cargo.toml"; const MPC_CONTRACT_OUT_DIR: &str = "target/near/contract-noabi"; const MPC_CONTRACT_BENCH_OUT_DIR: &str = "target/near/contract-noabi-bench"; const MPC_CONTRACT_SANDBOX_OUT_DIR: &str = "target/near/contract-noabi-sandbox"; @@ -13,6 +14,7 @@ static CONTRACT_WITH_BENCH_METHODS: OnceLock> = OnceLock::new(); static CONTRACT_WITH_SANDBOX_TEST_METHODS: OnceLock> = OnceLock::new(); static MIGRATION_CONTRACT: OnceLock> = OnceLock::new(); static PARALLEL_CONTRACT: OnceLock> = OnceLock::new(); +static STUB_TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -54,3 +56,8 @@ pub fn migration_contract() -> &'static [u8] { pub fn parallel_contract() -> &'static [u8] { PARALLEL_CONTRACT.get_or_init(|| ContractBuilder::new(PARALLEL_CONTRACT_MANIFEST).build()) } + +pub fn stub_tee_verifier_contract() -> &'static [u8] { + STUB_TEE_VERIFIER_CONTRACT + .get_or_init(|| ContractBuilder::new(STUB_TEE_VERIFIER_MANIFEST).build()) +} diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index ce9690131e..11f58f9fc6 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -2,12 +2,14 @@ use std::collections::BTreeSet; use super::transactions::all_receipts_successful; use mpc_contract::tee::tee_state::NodeId; -use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; -use near_mpc_contract_interface::method_names; -use near_mpc_contract_interface::types::{ - Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold, +use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash}; +use near_mpc_contract_interface::{ + method_names, + types::{Attestation, Ed25519PublicKey, Participants, ProtocolContractState, Threshold}, +}; +use near_workspaces::{ + Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, }; -use near_workspaces::{Account, Contract, result::ExecutionFinalResult}; pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract @@ -40,21 +42,66 @@ pub async fn get_tee_accounts(contract: &Contract) -> anyhow::Result anyhow::Result { - let result = account + submit_participant_info_with_deposit( + account, + contract, + attestation, + tls_key, + NearToken::from_near(0), + ) + .await +} + +pub async fn submit_participant_info_with_deposit( + account: &Account, + contract: &Contract, + attestation: &Attestation, + tls_key: &Ed25519PublicKey, + deposit: NearToken, +) -> anyhow::Result { + Ok(account .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) .args_json((attestation, tls_key)) + .deposit(deposit) .max_gas() .transact() - .await?; - dbg!(&result); - Ok(result) + .await?) +} + +pub async fn has_pending_attestation( + contract: &Contract, + account_id: &AccountId, +) -> anyhow::Result { + Ok(contract + .view("has_pending_attestation") + .args_json(serde_json::json!({ "account_id": account_id })) + .await? + .json()?) +} + +pub async fn vote_tee_verifier_change( + account: &Account, + contract: &Contract, + candidate_account_id: &AccountId, + expected_code_hash: [u8; 32], +) -> anyhow::Result<()> { + let expected_code_hash = TeeVerifierCodeHash::new(expected_code_hash); + all_receipts_successful( + account + .call(contract.id(), method_names::VOTE_TEE_VERIFIER_CHANGE) + .args_json(serde_json::json!({ + "candidate_account_id": candidate_account_id, + "expected_code_hash": expected_code_hash, + })) + .transact() + .await?, + ) } pub async fn get_participant_attestation( 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 4d39c43f38..7f626962d0 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -558,6 +558,39 @@ expression: abi } } }, + { + "name": "fail_attestation_submission", + "kind": "view", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "reason", + "type_schema": { + "declaration": "String", + "definitions": { + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + } + }, { "name": "fail_on_timeout", "kind": "view", @@ -920,6 +953,40 @@ expression: abi } } }, + { + "name": "on_attestation_verified", + "doc": " Yield-resume callback for a [`Attestation::Dstack`] submission. On\n success it resolves the caller's transaction; on a rejection or the\n ~200-block timeout it cleans up, refunds, and fails from a separate\n receipt.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "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" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/AttestationResult" + } + } + ], + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/PromiseOrValueNull" + } + } + }, { "name": "os_measurement_votes", "doc": " Returns the current OS measurement votes, showing each participant's vote.", @@ -983,6 +1050,10 @@ expression: abi "fail_on_timeout_tera_gas", "u64" ], + [ + "fail_attestation_submission_tera_gas", + "u64" + ], [ "clean_tee_status_tera_gas", "u64" @@ -1006,6 +1077,18 @@ expression: abi [ "remove_non_participant_tee_verifier_votes_tera_gas", "u64" + ], + [ + "verifier_tera_gas", + "u64" + ], + [ + "resolve_verification_tera_gas", + "u64" + ], + [ + "on_attestation_verified_tera_gas", + "u64" ] ] }, @@ -1259,6 +1342,470 @@ expression: abi ] } }, + { + "name": "resolve_verification", + "doc": " Verify-quote callback: maps the verifier's response to an [`AttestationResult`]\n and resumes the yield.", + "kind": "call", + "modifiers": [ + "private" + ], + "params": { + "serialization_type": "json", + "args": [ + { + "name": "node_id", + "type_schema": { + "$ref": "#/definitions/NodeId" + } + } + ] + }, + "callbacks": [ + { + "serialization_type": "borsh", + "type_schema": { + "declaration": "VerificationResult", + "definitions": { + "EnclaveReport": { + "Struct": [ + [ + "cpu_svn", + "[u8; 16]" + ], + [ + "misc_select", + "u32" + ], + [ + "reserved1", + "[u8; 28]" + ], + [ + "attributes", + "[u8; 16]" + ], + [ + "mr_enclave", + "[u8; 32]" + ], + [ + "reserved2", + "[u8; 32]" + ], + [ + "mr_signer", + "[u8; 32]" + ], + [ + "reserved3", + "[u8; 96]" + ], + [ + "isv_prod_id", + "u16" + ], + [ + "isv_svn", + "u16" + ], + [ + "reserved4", + "[u8; 60]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "Report": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "SgxEnclave", + "Report__SgxEnclave" + ], + [ + 1, + "TD10", + "Report__TD10" + ], + [ + 2, + "TD15", + "Report__TD15" + ] + ] + } + }, + "Report__SgxEnclave": { + "Struct": [ + "EnclaveReport" + ] + }, + "Report__TD10": { + "Struct": [ + "TDReport10" + ] + }, + "Report__TD15": { + "Struct": [ + "TDReport15" + ] + }, + "String": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "TDReport10": { + "Struct": [ + [ + "tee_tcb_svn", + "[u8; 16]" + ], + [ + "mr_seam", + "[u8; 48]" + ], + [ + "mr_signer_seam", + "[u8; 48]" + ], + [ + "seam_attributes", + "[u8; 8]" + ], + [ + "td_attributes", + "[u8; 8]" + ], + [ + "xfam", + "[u8; 8]" + ], + [ + "mr_td", + "[u8; 48]" + ], + [ + "mr_config_id", + "[u8; 48]" + ], + [ + "mr_owner", + "[u8; 48]" + ], + [ + "mr_owner_config", + "[u8; 48]" + ], + [ + "rt_mr0", + "[u8; 48]" + ], + [ + "rt_mr1", + "[u8; 48]" + ], + [ + "rt_mr2", + "[u8; 48]" + ], + [ + "rt_mr3", + "[u8; 48]" + ], + [ + "report_data", + "[u8; 64]" + ] + ] + }, + "TDReport15": { + "Struct": [ + [ + "base", + "TDReport10" + ], + [ + "tee_tcb_svn2", + "[u8; 16]" + ], + [ + "mr_service_td", + "[u8; 48]" + ] + ] + }, + "TcbStatus": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "UpToDate", + "TcbStatus__UpToDate" + ], + [ + 1, + "OutOfDateConfigurationNeeded", + "TcbStatus__OutOfDateConfigurationNeeded" + ], + [ + 2, + "OutOfDate", + "TcbStatus__OutOfDate" + ], + [ + 3, + "ConfigurationAndSWHardeningNeeded", + "TcbStatus__ConfigurationAndSWHardeningNeeded" + ], + [ + 4, + "ConfigurationNeeded", + "TcbStatus__ConfigurationNeeded" + ], + [ + 5, + "SWHardeningNeeded", + "TcbStatus__SWHardeningNeeded" + ], + [ + 6, + "Revoked", + "TcbStatus__Revoked" + ] + ] + } + }, + "TcbStatusWithAdvisory": { + "Struct": [ + [ + "status", + "TcbStatus" + ], + [ + "advisory_ids", + "Vec" + ] + ] + }, + "TcbStatus__ConfigurationAndSWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__ConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__OutOfDate": { + "Struct": null + }, + "TcbStatus__OutOfDateConfigurationNeeded": { + "Struct": null + }, + "TcbStatus__Revoked": { + "Struct": null + }, + "TcbStatus__SWHardeningNeeded": { + "Struct": null + }, + "TcbStatus__UpToDate": { + "Struct": null + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "String" + } + }, + "Vec": { + "Sequence": { + "length_width": 4, + "length_range": { + "start": 0, + "end": 4294967295 + }, + "elements": "u8" + } + }, + "VerificationResult": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "Verified", + "VerificationResult__Verified" + ], + [ + 1, + "Rejected", + "VerificationResult__Rejected" + ] + ] + } + }, + "VerificationResult__Rejected": { + "Struct": [ + "VerifierError" + ] + }, + "VerificationResult__Verified": { + "Struct": [ + "VerifiedReport" + ] + }, + "VerifiedReport": { + "Struct": [ + [ + "status", + "String" + ], + [ + "advisory_ids", + "Vec" + ], + [ + "report", + "Report" + ], + [ + "ppid", + "Vec" + ], + [ + "qe_status", + "TcbStatusWithAdvisory" + ], + [ + "platform_status", + "TcbStatusWithAdvisory" + ] + ] + }, + "VerifierError": { + "Enum": { + "tag_width": 1, + "variants": [ + [ + 0, + "DcapVerification", + "VerifierError__DcapVerification" + ] + ] + } + }, + "VerifierError__DcapVerification": { + "Struct": [ + "String" + ] + }, + "[u8; 16]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 16, + "end": 16 + }, + "elements": "u8" + } + }, + "[u8; 28]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 28, + "end": 28 + }, + "elements": "u8" + } + }, + "[u8; 32]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 32, + "end": 32 + }, + "elements": "u8" + } + }, + "[u8; 48]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 48, + "end": 48 + }, + "elements": "u8" + } + }, + "[u8; 60]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 60, + "end": 60 + }, + "elements": "u8" + } + }, + "[u8; 64]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 64, + "end": 64 + }, + "elements": "u8" + } + }, + "[u8; 8]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 8, + "end": 8 + }, + "elements": "u8" + } + }, + "[u8; 96]": { + "Sequence": { + "length_width": 0, + "length_range": { + "start": 96, + "end": 96 + }, + "elements": "u8" + } + }, + "u16": { + "Primitive": 2 + }, + "u32": { + "Primitive": 4 + }, + "u8": { + "Primitive": 1 + } + } + } + } + ] + }, { "name": "respond", "kind": "call", @@ -1536,7 +2083,7 @@ expression: abi }, { "name": "submit_participant_info", - "doc": " (Prospective) Participants can submit their tee participant information through this\n endpoint.", + "doc": " Submit a TEE attestation for a current or prospective participant.\n\n - [`Attestation::Mock`] is verified synchronously.\n - [`Attestation::Dstack`] is verified asynchronously, by yielding on a\n cross-contract verify-quote call. It rejects a second submission from\n the same account while one is still in flight.\n\n The attached deposit pays for storage on success, and is refunded on failure.", "kind": "call", "modifiers": [ "payable" @@ -2442,6 +2989,28 @@ expression: abi } ] }, + "AttestationResult": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Ok" + ] + }, + { + "type": "object", + "required": [ + "Err" + ], + "properties": { + "Err": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "AuthenticatedAccountId": { "description": "An account ID that has been authenticated (i.e., the caller is this account).", "type": "string" @@ -2714,14 +3283,18 @@ expression: abi "clean_tee_status_tera_gas", "cleanup_orphaned_node_migrations_tera_gas", "contract_upgrade_deposit_tera_gas", + "fail_attestation_submission_tera_gas", "fail_on_timeout_tera_gas", "key_event_timeout_blocks", + "on_attestation_verified_tera_gas", "remove_non_participant_tee_verifier_votes_tera_gas", "remove_non_participant_update_votes_tera_gas", + "resolve_verification_tera_gas", "return_ck_and_clean_state_on_success_call_tera_gas", "return_signature_and_clean_state_on_success_call_tera_gas", "sign_call_gas_attachment_requirement_tera_gas", - "tee_upgrade_deadline_duration_seconds" + "tee_upgrade_deadline_duration_seconds", + "verifier_tera_gas" ], "properties": { "ckd_call_gas_attachment_requirement_tera_gas": { @@ -2760,6 +3333,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": "integer", @@ -2772,6 +3351,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": "integer", @@ -2784,6 +3369,12 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": "integer", @@ -2807,6 +3398,12 @@ expression: abi "type": "integer", "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 } } }, @@ -3374,6 +3971,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "fail_attestation_submission_tera_gas": { + "description": "Prepaid gas for a `fail_attestation_submission` call.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "fail_on_timeout_tera_gas": { "description": "Prepaid gas for a `fail_on_timeout` call.", "type": [ @@ -3392,6 +3998,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "on_attestation_verified_tera_gas": { + "description": "Prepaid gas for the `on_attestation_verified` yield-callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "remove_non_participant_tee_verifier_votes_tera_gas": { "description": "Prepaid gas for a `remove_non_participant_tee_verifier_votes` call.", "type": [ @@ -3410,6 +4025,15 @@ expression: abi "format": "uint64", "minimum": 0.0 }, + "resolve_verification_tera_gas": { + "description": "Prepaid gas for the `resolve_verification` callback.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "return_ck_and_clean_state_on_success_call_tera_gas": { "description": "Prepaid gas for a `return_ck_and_clean_state_on_success` call.", "type": [ @@ -3445,6 +4069,15 @@ expression: abi ], "format": "uint64", "minimum": 0.0 + }, + "verifier_tera_gas": { + "description": "Gas attached to the cross-contract `verify_quote` call on the verifier.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 } } }, @@ -4051,6 +4684,9 @@ expression: abi } } }, + "PromiseOrValueNull": { + "type": "null" + }, "PromiseOrValueSignatureResponse": { "oneOf": [ { diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 25585d68ea..1fd671e6a9 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -346,10 +346,9 @@ impl Attestation { } } - /// Full local verification: runs DCAP (`dcap_qvl::verify::verify`) and then - /// the post-DCAP checks. Behind the `local-verify` feature, which pulls in - /// `dcap-qvl`. Used by off-chain callers and, today, by `mpc-contract`. - // TODO(#3264): contract drops this once DCAP moves to the verifier contract. + /// Full local verification: runs the DCAP quote verification and then the + /// post-DCAP checks. Behind the `local-verify` feature, which pulls in + /// `dcap-qvl`. Used by off-chain callers (node, tee-authority, attestation-cli). #[cfg(feature = "local-verify")] pub fn verify_locally( &self, diff --git a/crates/near-mpc-contract-interface/src/method_names.rs b/crates/near-mpc-contract-interface/src/method_names.rs index a6ae25a80d..fde652c2d8 100644 --- a/crates/near-mpc-contract-interface/src/method_names.rs +++ b/crates/near-mpc-contract-interface/src/method_names.rs @@ -64,6 +64,12 @@ pub const RETURN_SIGNATURE_AND_CLEAN_STATE_ON_SUCCESS: &str = pub const RETURN_CK_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_ck_and_clean_state_on_success"; pub const RETURN_VERIFY_FOREIGN_TX_AND_CLEAN_STATE_ON_SUCCESS: &str = "return_verify_foreign_tx_and_clean_state_on_success"; +pub const ON_ATTESTATION_VERIFIED: &str = "on_attestation_verified"; +pub const RESOLVE_VERIFICATION: &str = "resolve_verification"; +pub const FAIL_ATTESTATION_SUBMISSION: &str = "fail_attestation_submission"; + +// TEE verifier contract (the method `mpc-contract` calls cross-contract) +pub const VERIFY_QUOTE: &str = "verify_quote"; // View methods pub const STATE: &str = "state"; diff --git a/crates/near-mpc-contract-interface/src/types/config.rs b/crates/near-mpc-contract-interface/src/types/config.rs index 75646b5a5a..4816084bac 100644 --- a/crates/near-mpc-contract-interface/src/types/config.rs +++ b/crates/near-mpc-contract-interface/src/types/config.rs @@ -37,6 +37,8 @@ pub struct InitConfig { pub return_ck_and_clean_state_on_success_call_tera_gas: Option, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: Option, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: Option, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: Option, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -49,6 +51,12 @@ pub struct InitConfig { 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, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: Option, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: Option, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: Option, } /// Configuration parameters of the contract. @@ -87,6 +95,8 @@ pub struct Config { pub return_ck_and_clean_state_on_success_call_tera_gas: u64, /// Prepaid gas for a `fail_on_timeout` call. pub fail_on_timeout_tera_gas: u64, + /// Prepaid gas for a `fail_attestation_submission` call. + pub fail_attestation_submission_tera_gas: u64, /// Prepaid gas for a `clean_tee_status` call. pub clean_tee_status_tera_gas: u64, /// Prepaid gas for the reshare-time `clean_invalid_attestations` promise. @@ -99,6 +109,12 @@ pub struct Config { 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, + /// Gas attached to the cross-contract `verify_quote` call on the verifier. + pub verifier_tera_gas: u64, + /// Prepaid gas for the `resolve_verification` callback. + pub resolve_verification_tera_gas: u64, + /// Prepaid gas for the `on_attestation_verified` yield-callback. + pub on_attestation_verified_tera_gas: u64, } #[cfg(test)] @@ -117,12 +133,16 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: Some(7), return_ck_and_clean_state_on_success_call_tera_gas: Some(7), fail_on_timeout_tera_gas: Some(2), + fail_attestation_submission_tera_gas: Some(2), clean_tee_status_tera_gas: Some(10), clean_invalid_attestations_tera_gas: Some(10), 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), + verifier_tera_gas: Some(100), + resolve_verification_tera_gas: Some(60), + on_attestation_verified_tera_gas: Some(10), }; let json = serde_json::to_string(&original_config).unwrap(); let serialized_and_deserialized_config: InitConfig = serde_json::from_str(&json).unwrap(); @@ -167,12 +187,16 @@ mod tests { return_signature_and_clean_state_on_success_call_tera_gas: None, return_ck_and_clean_state_on_success_call_tera_gas: None, fail_on_timeout_tera_gas: None, + fail_attestation_submission_tera_gas: None, clean_tee_status_tera_gas: None, clean_invalid_attestations_tera_gas: None, 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, + verifier_tera_gas: None, + resolve_verification_tera_gas: None, + on_attestation_verified_tera_gas: None, }; assert_eq!(default_config, config_with_all_values_as_none); diff --git a/crates/test-tee-verifier/Cargo.toml b/crates/test-tee-verifier/Cargo.toml new file mode 100644 index 0000000000..f9612d5569 --- /dev/null +++ b/crates/test-tee-verifier/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "test-tee-verifier" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +# A test-only stub of the `tee-verifier` contract: `verify_quote` returns a +# response the test chose at init, instead of running real `dcap-qvl`. Lets the +# `mpc-contract` sandbox tests drive every branch of the async attestation flow +# (Verified / Rejected / post-DCAP failure / no-verdict) deterministically. +# Speaks the same `tee-verifier-interface` Borsh DTOs as the real verifier, so +# `mpc-contract` cannot tell them apart. + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +# Enabled by `cargo near build` / `--all-features` for ABI generation, mirroring +# the real `tee-verifier`: pulls in the borsh schema for the wire DTOs. +abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] + +[dependencies] +borsh = { workspace = true } +near-sdk = { workspace = true } +tee-verifier-interface = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true, features = ["custom"] } + +[lints] +workspace = true diff --git a/crates/test-tee-verifier/src/lib.rs b/crates/test-tee-verifier/src/lib.rs new file mode 100644 index 0000000000..bd5168f1ef --- /dev/null +++ b/crates/test-tee-verifier/src/lib.rs @@ -0,0 +1,82 @@ +//! Test-only stub of the `tee-verifier` contract. +//! +//! `verify_quote` ignores its inputs and returns a response fixed at init time, +//! instead of running real `dcap_qvl::verify`. This lets `mpc-contract` sandbox +//! tests drive every branch of the async attestation flow deterministically: +//! a `Verified` report (which the test supplies so it matches the fixture's +//! post-DCAP expectations), a `Rejected` verdict, or a panic (the no-verdict / +//! verifier-unreachable path). +//! +//! It speaks the same `tee-verifier-interface` Borsh DTOs and uses the same +//! `#[result_serializer(borsh)]` as the real verifier, so `mpc-contract` cannot +//! tell the two apart. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::{env, near}; +use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; + +// Match the real verifier's getrandom handling on wasm so the crate links. +#[cfg(target_arch = "wasm32")] +fn randomness_unsupported(_buf: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) +} +#[cfg(target_arch = "wasm32")] +getrandom::register_custom_getrandom!(randomness_unsupported); + +/// What the stub's `verify_quote` should do, chosen by the test at deploy time. +#[expect(clippy::large_enum_variant)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(borsh::BorshSchema) +)] +pub enum StubResponse { + /// Return `VerificationResult::Verified` with this exact report. Tests that + /// want the post-DCAP checks to pass supply the report obtained from the + /// real fixture quote (e.g. via `DstackAttestation::dcap_report`). + Verified(tee_verifier_interface::VerifiedReport), + /// Return `VerificationResult::Rejected` with this reason. + Rejected(String), + /// Panic, simulating an unreachable / crashing verifier (the no-verdict + /// path that `mpc-contract` resolves via the yield timeout). + Panic, +} + +#[derive(Debug)] +#[near(contract_state)] +pub struct TestTeeVerifier { + response: StubResponse, +} + +impl Default for TestTeeVerifier { + fn default() -> Self { + // A contract must be initialized via `new`; default would never be used + // by a test, but `#[near(contract_state)]` requires the bound. + env::panic_str("TestTeeVerifier must be initialized with `new`") + } +} + +#[near] +impl TestTeeVerifier { + #[init] + pub fn new(#[serializer(borsh)] response: StubResponse) -> Self { + Self { response } + } + + /// Stub mirror of `tee_verifier::verify_quote`: ignores `quote`/`collateral` + /// and returns the canned response. Panics on `StubResponse::Panic`. + #[result_serializer(borsh)] + pub fn verify_quote( + &self, + #[serializer(borsh)] _quote: QuoteBytes, + #[serializer(borsh)] _collateral: Collateral, + ) -> VerificationResult { + match &self.response { + StubResponse::Verified(report) => VerificationResult::Verified(report.clone()), + StubResponse::Rejected(reason) => { + VerificationResult::Rejected(VerifierError::DcapVerification(reason.to_string())) + } + StubResponse::Panic => env::panic_str("stub verifier: simulated crash"), + } + } +} diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index 6334ab1536..9b64d77631 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -15,5 +15,9 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { 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, + verifier_tera_gas: value + 14, + resolve_verification_tera_gas: value + 15, + on_attestation_verified_tera_gas: value + 16, + fail_attestation_submission_tera_gas: value + 17, } } diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index 0346ce6c69..9f14734746 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -337,12 +337,14 @@ The contract gains two new state fields: pub struct MpcContract { // ... existing fields ... - /// The locked account `mpc-contract` currently trusts as the verifier. - /// `submit_participant_info` calls `verify_quote` on this account. - /// Mutated only by the threshold-crossing vote above; the mutation - /// re-routes future submissions and does not touch already-stored - /// attestations. - tee_verifier_account_id: AccountId, + /// The locked account `mpc-contract` currently trusts as the verifier, or + /// `None` until participants vote one in (a `Dstack` `submit_participant_info` + /// is then rejected with `VerifierNotConfigured`). `submit_participant_info` + /// calls `verify_quote` on this account. Mutated only by the threshold-crossing + /// vote above; the mutation re-routes future submissions and does not touch + /// already-stored attestations. (Making this non-`Option` once a verifier is + /// voted in is the follow-up #3639.) + tee_verifier_account_id: Option, /// Pending votes for changing `tee_verifier_account_id`. Each voter is an /// active MPC participant; each proposal is hashed from @@ -381,7 +383,7 @@ sequenceDiagram ### `mpc-contract::submit_participant_info` -The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. The return type is [`PromiseOrValue<()>`](https://docs.rs/near-sdk/5.26.1/near_sdk/enum.PromiseOrValue.html), `near-sdk`'s "sometimes synchronous, sometimes a Promise chain" type: `Mock` attestations return `Value(())` immediately, and `Dstack` attestations return the yielded `Promise` from [`env::promise_yield_create`][promise-yield-create], which the runtime resolves either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: +The method splits across three receipts joined by yield-resume — see [§Submission flow](#submission-flow) above for the architecture. It returns `Result<(), Error>`, like the existing yield producers (`sign` / `request_app_private_key` / `verify_foreign_transaction`): `Mock` attestations are verified synchronously and return `Ok(())`; `Dstack` attestations register a yield via [`env::promise_yield_create`][promise-yield-create] and end on `enqueue_yield_request` so that its `env::promise_return` is the method's result. The runtime resolves the yield either when `resolve_verification` calls [`env::promise_yield_resume`][promise-yield-resume] or after ~200 blocks of silence. The post-DCAP checks and the `stored_attestations` insert live in `resolve_verification`, not in the yield-callback — that's what keeps `on_attestation_verified` trivial enough to match the sign-request callback [`return_signature_and_clean_state_on_success`][sign-yield-callback]. Draft implementation: ```rust impl MpcContract { @@ -389,15 +391,15 @@ impl MpcContract { &mut self, attestation: Attestation, tls_pk: Ed25519PublicKey, - ) -> PromiseOrValue<()> { + ) -> Result<(), Error> { // Existing convention: caller must be the signer of this transaction, // not a relayer or proxy. let account_id = Self::assert_caller_is_signer(); match attestation { - // Unchanged from today. + // Synchronous: no DCAP, verified and stored in this call. Attestation::Mock(mock) => { - self.verify_mock_synchronously(mock, tls_pk); - PromiseOrValue::Value(()) + self.tee_state.add_mock_participant(node_id, mock, ...)?; + Ok(()) } // Dstack: yield-resume. Attestation::Dstack(dstack) => { @@ -406,18 +408,45 @@ impl MpcContract { // runtime timeout) is rejected outright — same shape as // duplicate sign requests. if self.pending_attestations.contains_key(&account_id) { - env::panic_str("verification already pending"); + return Err(TeeError::VerificationAlreadyPending.into()); } + // Refuse until a verifier is voted in: there is no account to + // call `verify_quote` on. + let Some(verifier_account_id) = self.tee_verifier_account_id.clone() else { + return Err(TeeError::VerifierNotConfigured.into()); + }; - let (quote, collateral) = extract_dcap_inputs(&dstack); let attached_deposit = env::attached_deposit(); - // Reuses the existing `enqueue_yield_request` helper that - // wraps `env::promise_yield_create`. The helper allocates - // `data_id`, registers `on_attestation_verified` as the - // yield-callback, and surfaces `data_id` via the `insert` - // closure so we can stash it together with the rest of the - // `PendingAttestation` fields. + // Cross-contract call to the verifier, built first so the + // `enqueue_yield_request` below stays the final host call. Its + // `.then` callback (`resolve_verification`) is the bridge that + // turns the verifier's response into a `promise_yield_resume` on + // the yield this method registers next. Quote/collateral are + // serialized by reference so `dstack` can move into the pending + // entry without cloning the (large) payload. + Promise::new(verifier_account_id) + .function_call( + "verify_quote".into(), + borsh::to_vec(&(&dstack.quote, &dstack.collateral)).unwrap(), + NearToken::from_yoctonear(0), + Gas::from_tgas(VERIFIER_GAS_TGAS), + ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) + .resolve_verification(node_id.clone()), + ) + .detach(); + + // Reuses the existing `enqueue_yield_request` helper that wraps + // `env::promise_yield_create`. The helper allocates `data_id`, + // registers `on_attestation_verified` as the yield-callback, and + // surfaces `data_id` via the `insert` closure so we can stash it + // together with the rest of the `PendingAttestation` fields. It + // calls `env::promise_return` last, making the yield the method's + // result — so we just return `Ok(())` (no `value_return` that + // would override it). self.enqueue_yield_request( "on_attestation_verified", borsh::to_vec(&account_id).unwrap(), @@ -434,28 +463,7 @@ impl MpcContract { ); }, ); - - // Cross-contract call to the verifier. Its `.then` callback - // (`resolve_verification`) is the bridge that turns the - // verifier's response into a `promise_yield_resume` on the - // yield this method registered above. - Promise::new(self.tee_verifier_account_id.clone()) - .function_call( - "verify_quote".into(), - borsh::to_vec(&(quote, collateral)).unwrap(), - NearToken::from_yoctonear(0), - Gas::from_tgas(VERIFIER_GAS_TGAS), - ) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(Gas::from_tgas(RESOLVE_GAS_TGAS)) - .resolve_verification(account_id), - ); - - // The yield handle was returned by `enqueue_yield_request` - // via `env::promise_return`, so the caller's `Promise` - // resolves with whatever the yield-callback returns. - PromiseOrValue::Value(()) + Ok(()) } } } @@ -562,18 +570,27 @@ impl MpcContract { &mut self, account_id: AccountId, #[callback_result] result: Result, - ) -> Result<(), String> { - match result { - Ok(FinalOutcome::Ok) => Ok(()), - Ok(FinalOutcome::Err(reason)) => Err(reason), + ) -> PromiseOrValue<()> { + let reason = match result { + Ok(FinalOutcome::Ok) => return PromiseOrValue::Value(()), + Ok(FinalOutcome::Err(reason)) => reason, Err(_promise_err) => { if let Some(pending) = self.pending_attestations.remove(&account_id) { refund_deposit(&account_id, pending.attached_deposit); log!("yield timeout for {account_id}: refunded and cleaned up"); } - Err("verifier did not respond within yield-resume window".to_string()) + "verifier did not respond within yield-resume window".to_string() } - } + }; + // Fail the submitter's transaction from a SEPARATE receipt: a panic here + // would roll back the cleanup above. + let promise = Promise::new(env::current_account_id()).function_call( + "fail_attestation_submission".into(), + borsh::to_vec(&reason).unwrap(), + NearToken::from_near(0), + Gas::from_tgas(FAIL_GAS_TGAS), + ); + PromiseOrValue::Promise(promise.as_return()) } } diff --git a/docs/localnet/tee-localnet.md b/docs/localnet/tee-localnet.md index 054a39042f..70ab1573b7 100644 --- a/docs/localnet/tee-localnet.md +++ b/docs/localnet/tee-localnet.md @@ -276,7 +276,7 @@ near transaction view-status network-config mpc-localnet ``` ``` -(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: TeeQuoteStatus is invalid: the allowed mpc image hashes list is empty" +(ExecutionError("Smart contract panicked: Invalid TEE Remote Attestation.: attestation verification failed: the allowed mpc image hashes list is empty" ``` ### Vote Commands diff --git a/docs/running-an-mpc-node-in-tdx-external-guide.md b/docs/running-an-mpc-node-in-tdx-external-guide.md index 8d334486b1..9f374ec205 100644 --- a/docs/running-an-mpc-node-in-tdx-external-guide.md +++ b/docs/running-an-mpc-node-in-tdx-external-guide.md @@ -2024,7 +2024,7 @@ The error after `err=` is the NEAR runtime error. Common ones: If the transaction reaches execution and the contract panics, the node logs only the generic retry line above; the actual message lives in the transaction receipt. Find the tx on `https://testnet.nearblocks.io/address/` and open the failed `submit_participant_info` call — the error appears under the action's status / logs. The contract wraps the attestation-side error like this: ``` -Invalid TEE Remote Attestation: TeeQuoteStatus is invalid: +Invalid TEE Remote Attestation: attestation verification failed: the submitted attestation failed verification, reason: Custom("...") ```