diff --git a/Cargo.lock b/Cargo.lock index 760d40a056..6eebe054a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11647,6 +11647,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", + "tee-verifier-interface", ] [[package]] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index b263bd9207..dc8ad7575d 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2846,6 +2846,13 @@ mod tests { use rstest::rstest; use sha2::{Digest, Sha256}; + use crate::tee::{ + test_utils::whitelist_dstack_measurements, verification_context::VerificationContext, + }; + use test_utils::attestation::{ + VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, + mock_dstack_attestation_inner, p2p_tls_key, verified_report, + }; use test_utils::contract_types::dummy_config; use threshold_signatures::confidential_key_derivation as ckd; use threshold_signatures::frost_core::Group as _; @@ -4542,6 +4549,76 @@ mod tests { .expect("Expected panic if predecessor != signer"); } + fn dstack_verification_setup() -> (MpcContract, VerificationContext) { + let (_, mut contract, _) = basic_setup(Curve::Edwards25519, &mut OsRng); + let contract_account_id = env::current_account_id(); + let context = VMContextBuilder::new() + .current_account_id(contract_account_id.clone()) + .predecessor_account_id(contract_account_id) + .attached_deposit(MINIMUM_ATTESTATION_STORAGE_DEPOSIT) + .block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000) + .build(); + testing_env!(context); + + contract.tee_state = TeeState::default(); + whitelist_dstack_measurements( + &mut contract.tee_state, + image_digest(), + launcher_image_hash(), + ); + + let node_id = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: Ed25519PublicKey(p2p_tls_key()), + account_public_key: Ed25519PublicKey(account_key()), + }; + let attestation = mock_dstack_attestation_inner(); + ( + contract, + VerificationContext { + node_id, + attestation, + }, + ) + } + + #[test] + fn resolve_verification__should_store_on_verified_verdict() { + // Given + let (mut contract, context) = dstack_verification_setup(); + let node_id = context.node_id.clone(); + + // When + let result = contract + .resolve_verification(context, Ok(VerificationResult::Verified(verified_report()))); + + // Then + // assert_matches! requires Debug, which PromiseOrValue doesn't implement + assert!(matches!(result, PromiseOrValue::Value(()))); + assert_eq!(contract.tee_state.stored_attestations.len(), 1); + let stored = contract + .tee_state + .stored_attestations + .get(&node_id.tls_public_key) + .expect("attestation must be stored"); + assert_eq!(stored.node_id, node_id); + } + + #[test] + fn resolve_verification__should_return_fail_promise_and_store_nothing_on_verifier_unavailable() + { + // Given + let (mut contract, context) = dstack_verification_setup(); + + // When + let result = contract.resolve_verification(context, Err(PromiseError::Failed)); + + // Then + // assert_matches! requires Debug, which PromiseOrValue doesn't implement + assert!(matches!(result, PromiseOrValue::Promise(_))); + assert!(contract.tee_state.stored_attestations.is_empty()); + } + #[test] #[should_panic(expected = "Caller must be an attested participant")] fn test_attested_but_not_participant_panics() { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index c3fb05f0f8..9d10d68f72 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -579,7 +579,7 @@ mod tests { authenticate_as, bogus_ed25519_near_public_key, bogus_ed25519_public_key, create_node_id, gen_participant, gen_participants, node_id_for, }; - use crate::tee::test_utils::set_block_timestamp; + use crate::tee::test_utils::{set_block_timestamp, whitelist_dstack_measurements}; use assert_matches::assert_matches; use mpc_attestation::attestation::MockAttestation; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; @@ -587,6 +587,10 @@ mod tests { use near_sdk::test_utils::VMContextBuilder; use near_sdk::testing_env; use std::time::Duration; + use test_utils::attestation::{ + VALID_ATTESTATION_TIMESTAMP, account_key, image_digest, launcher_image_hash, + mock_dstack_attestation_inner, p2p_tls_key, verified_report, + }; /// Helper to set up the testing environment with a specific signer fn set_signer(account_id: &AccountId, public_key: &near_sdk::PublicKey) { @@ -1412,6 +1416,57 @@ mod tests { ) } + #[test] + fn verify_and_store_dstack__should_reject_and_store_nothing_when_post_dcap_checks_fail() { + // Given + let mut tee_state = TeeState::default(); + let dstack = mock_dstack_attestation_inner(); + let node_id = node_id_for(&"alice.near".parse().unwrap()); + + // When + let result = + tee_state.verify_and_store_dstack(node_id, &dstack, &verified_report(), Duration::MAX); + + // Then + assert_matches!( + result, + Err(AttestationSubmissionError::InvalidAttestation(_)) + ); + assert!(tee_state.stored_attestations.is_empty()); + } + + #[test] + fn verify_and_store_dstack__should_store_when_all_post_dcap_checks_pass() { + // Given + set_block_timestamp(VALID_ATTESTATION_TIMESTAMP * 1_000_000_000); + let mut tee_state = TeeState::default(); + assert_eq!(tee_state.stored_attestations.len(), 0); + whitelist_dstack_measurements(&mut tee_state, image_digest(), launcher_image_hash()); + let node_id = NodeId { + account_id: "alice.near".parse().unwrap(), + tls_public_key: Ed25519PublicKey(p2p_tls_key()), + account_public_key: Ed25519PublicKey(account_key()), + }; + let dstack = mock_dstack_attestation_inner(); + + // When + let result = tee_state.verify_and_store_dstack( + node_id.clone(), + &dstack, + &verified_report(), + Duration::MAX, + ); + + // Then + assert_matches!(result, Ok(ParticipantInsertion::NewlyInsertedParticipant)); + assert_eq!(tee_state.stored_attestations.len(), 1); + let stored = tee_state + .stored_attestations + .get(&node_id.tls_public_key) + .expect("attestation must be stored"); + assert_eq!(stored.node_id, node_id); + } + /// Stale CodeHashesVotes entries from removed participants must not count toward /// quorum after resharing. /// diff --git a/crates/contract/src/tee/test_utils.rs b/crates/contract/src/tee/test_utils.rs index 5c671d702f..f0281434a6 100644 --- a/crates/contract/src/tee/test_utils.rs +++ b/crates/contract/src/tee/test_utils.rs @@ -4,10 +4,13 @@ //! attestation behavior, and general contract state management. use crate::primitives::test_utils::{gen_account_id, gen_seed}; +use crate::tee::{measurements::ContractExpectedMeasurements, tee_state::TeeState}; +use mpc_attestation::attestation::default_measurements; +use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; -use near_sdk::test_utils::VMContextBuilder; -use near_sdk::{BlockHeight, PublicKey, testing_env}; +use near_sdk::{BlockHeight, PublicKey, test_utils::VMContextBuilder, testing_env}; use rand::Rng; +use std::time::Duration; /// Test environment for managing VM context state. /// @@ -93,3 +96,15 @@ pub fn set_block_timestamp(timestamp_nanos: u64) { .build() ); } + +pub fn whitelist_dstack_measurements( + tee_state: &mut TeeState, + image: NodeImageHash, + launcher: LauncherImageHash, +) { + tee_state.whitelist_tee_proposal(image, Duration::MAX); + tee_state.add_launcher_image(launcher, Duration::MAX); + for &measurements in default_measurements() { + tee_state.add_measurement(ContractExpectedMeasurements::from(measurements)); + } +} diff --git a/crates/contract/tests/inprocess/attestation_submission.rs b/crates/contract/tests/inprocess/attestation_submission.rs index 0d3d0fac17..3a790ab3db 100644 --- a/crates/contract/tests/inprocess/attestation_submission.rs +++ b/crates/contract/tests/inprocess/attestation_submission.rs @@ -3,7 +3,7 @@ use super::common; use mpc_contract::{ MpcContract, - errors::Error, + errors::{Error, InvalidParameters, InvalidState, TeeError}, primitives::{ key_state::EpochId, participants::{ParticipantId, ParticipantInfo}, @@ -23,6 +23,7 @@ use near_account_id::AccountId; use near_sdk::{NearToken, test_utils::VMContextBuilder, testing_env}; use rstest::rstest; use std::time::Duration; +use test_utils::attestation::mock_dto_dstack_attestation; const SECOND: Duration = Duration::from_secs(1); const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64; @@ -32,7 +33,7 @@ const ATTESTATION_STORAGE_DEPOSIT: NearToken = const DEFAULT_PARTICIPANT_COUNT: usize = 3; const DEFAULT_THRESHOLD_SIZE: u64 = 2; -const DEFAUTL_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; +const DEFAULT_CONTRACT_PROTOCOL_STATE: ContractProtocolState = ContractProtocolState::Running; enum ContractProtocolState { Running, @@ -57,7 +58,7 @@ impl TestSetupBuilder { } } - fn with_partcipant_count(mut self, participant_count: usize) -> Self { + fn with_participant_count(mut self, participant_count: usize) -> Self { self.participant_count = Some(participant_count); self } @@ -72,6 +73,13 @@ impl TestSetupBuilder { self } + fn with_tee_upgrade_grace_period_seconds(self, seconds: u64) -> Self { + self.with_init_config(InitConfig { + tee_upgrade_deadline_duration_seconds: Some(seconds), + ..Default::default() + }) + } + fn with_contract_protocol_state( mut self, contract_protocol_state: ContractProtocolState, @@ -85,7 +93,7 @@ impl TestSetupBuilder { let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD_SIZE); let contract_protocol_state = self .contract_protocol_state - .unwrap_or(DEFAUTL_CONTRACT_PROTOCOL_STATE); + .unwrap_or(DEFAULT_CONTRACT_PROTOCOL_STATE); let participants = gen_participants(participant_count); let participants_list = participants.participants().clone(); @@ -224,14 +232,8 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { const PARTICIPANT_COUNT: usize = 2; const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -247,26 +249,12 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { .expect("victim attestation should be stored"); // When: an unrelated account submits an attestation that targets the victim's TLS key. - // The attacker context attaches a deposit large enough to cover any storage charge, - // so the call can only fail due to the ownership check — not `InsufficientDeposit`. let attacker_node = create_node_id( &"attacker.near".parse().unwrap(), &victim_node.tls_public_key, ); - testing_env!( - VMContextBuilder::new() - .signer_account_id(attacker_node.account_id.clone()) - .predecessor_account_id(attacker_node.account_id.clone()) - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); let attack_result = setup - .contract - .submit_participant_info( - Attestation::Mock(MockAttestation::Valid), - attacker_node.tls_public_key.clone(), - ) - .map(|_| ()); + .try_submit_attestation_for_node(&attacker_node, Attestation::Mock(MockAttestation::Valid)); // Then: the contract rejects the call with the TLS-ownership error and the victim's // entry is unchanged. @@ -284,6 +272,56 @@ fn submit_participant_info__should_reject_overwrite_from_other_account() { assert_eq!(stored_before, stored_after); } +/// Rejects a submission whose attached deposit is below the storage cost, so a caller +/// cannot store an attestation without paying for it. +#[test] +fn submit_participant_info__should_reject_when_deposit_is_below_storage_cost() { + // Given + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + let attached_deposit = NearToken::from_yoctonear(1); + testing_env!( + VMContextBuilder::new() + .signer_account_id(node.account_id.clone()) + .predecessor_account_id(node.account_id.clone()) + .attached_deposit(attached_deposit) + .build() + ); + + // When + let result = setup + .contract + .submit_participant_info( + Attestation::Mock(MockAttestation::Valid), + node.tls_public_key.clone(), + ) + .map(|_| ()); + + // Then + assert_matches!( + &result, + Err(Error::InvalidParameters(InvalidParameters::InsufficientDeposit { attached, required })) + if *attached == attached_deposit.as_yoctonear() && required > attached + ); +} + +/// Test that a `Dstack` submission is rejected when no verifier is configured. +#[test] +fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given + let mut setup = TestSetupBuilder::new().build(); + let node = setup.get_participant_node_ids()[0].clone(); + + // When + let result = setup.try_submit_attestation_for_node(&node, mock_dto_dstack_attestation()); + + // Then + assert_matches!( + &result, + Err(Error::TeeError(TeeError::VerifierNotConfigured)) + ); +} + /// **Test that `clean_tee_status()` is vote-only** — attestations for non-participants /// remain in `stored_attestations` after the call. Attestation pruning is handled by the /// separate `clean_invalid_attestations` endpoint. @@ -293,28 +331,15 @@ fn clean_tee_status__should_not_touch_attestations() { const PARTICIPANT_COUNT: usize = 2; // After resharing removed one participant const THRESHOLD: u64 = 2; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .build() - ); - // Create contract in Running state with 2 current participants let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); // Submit TEE info for current 2 participants (all have valid attestations) let valid_attestation = Attestation::Mock(MockAttestation::Valid); - let participant_nodes: Vec = setup - .participants_list - .iter() - .take(PARTICIPANT_COUNT) - .map(|(account_id, _, participant_info)| { - create_node_id(account_id, &participant_info.tls_public_key) - }) - .collect(); + let participant_nodes = setup.get_participant_node_ids(); for node_id in &participant_nodes { setup.submit_attestation_for_node(node_id, valid_attestation.clone()); } @@ -330,12 +355,11 @@ fn clean_tee_status__should_not_touch_attestations() { INITIAL_TEE_ACCOUNTS ); - let running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should be in Running state"), - }; - let participant_count = running_state.parameters.participants.participants.len(); - assert_eq!(participant_count, PARTICIPANT_COUNT); + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT + ); // When: clean_tee_status runs. setup.contract.clean_tee_status().unwrap(); @@ -347,17 +371,10 @@ fn clean_tee_status__should_not_touch_attestations() { ); // State should remain Running with same participant count - let final_running_state = match setup.contract.state() { - ProtocolContractState::Running(r) => r, - _ => panic!("Should still be Running after cleanup"), - }; - assert_eq!( - final_running_state - .parameters - .participants - .participants - .len(), - PARTICIPANT_COUNT + assert_matches!( + setup.contract.state(), + ProtocolContractState::Running(r) + if r.parameters.participants.participants.len() == PARTICIPANT_COUNT ); } @@ -372,15 +389,8 @@ fn clean_invalid_attestations__should_remove_expired_entries() { const EXPIRY_SECONDS: u64 = 1_000; const NOW_NS: u64 = 5_000 * NANOS_IN_SECOND; - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); - let mut setup = TestSetupBuilder::new() - .with_partcipant_count(PARTICIPANT_COUNT) + .with_participant_count(PARTICIPANT_COUNT) .with_threshold(THRESHOLD) .build(); @@ -394,10 +404,7 @@ fn clean_invalid_attestations__should_remove_expired_entries() { // init_running seeds one mock `Valid` attestation per participant. Overwrite the // first participant's entry with an expiring one, and add a brand-new entry for an // outsider account. - let participant_node = { - let (account_id, _, info) = &setup.participants_list[0]; - create_node_id(account_id, &info.tls_public_key) - }; + let participant_node = setup.get_participant_node_ids()[0].clone(); setup.submit_attestation_for_node(&participant_node, expiring_attestation.clone()); let stale_node = node_id_for(&"stale.near".parse().unwrap()); @@ -426,12 +433,6 @@ fn clean_invalid_attestations__should_remove_expired_entries() { #[test] fn clean_invalid_attestations__should_reject_when_not_running() { // Given: contract sitting in Initializing state. - testing_env!( - VMContextBuilder::new() - .attached_deposit(ATTESTATION_STORAGE_DEPOSIT) - .block_timestamp(0) - .build() - ); let mut setup = TestSetupBuilder::new() .with_contract_protocol_state(ContractProtocolState::Initializing) @@ -441,7 +442,10 @@ fn clean_invalid_attestations__should_reject_when_not_running() { let result = setup.contract.clean_invalid_attestations(100); // Then: the call errors without mutating state. - assert_matches!(result, Err(_)); + assert_matches!( + result, + Err(Error::InvalidState(InvalidState::ProtocolStateNotRunning)) + ); } macro_rules! assert_allowed_docker_image_hashes { @@ -473,13 +477,8 @@ fn only_latest_hash_after_grace_period() { const SECOND_ENTRY_TIME_NS: u64 = 4 * NANOS_IN_SECOND; // 1s const GRACE_PERIOD_NS: u64 = 10 * NANOS_IN_SECOND; // 10s - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_NS / NANOS_IN_SECOND) .build(); let old_hash = [1; 32]; @@ -518,12 +517,8 @@ fn latest_inserted_image_hash_takes_precedence_on_equal_time_stamps() { const INITIAL_TIME: u64 = 1; const GRACE_PERIOD: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD), - ..Default::default() - }; let mut setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD) .build(); let hash_1 = [1; 32]; @@ -561,13 +556,8 @@ fn hash_grace_period_depends_on_successor_entry_time_not_latest() { const THIRD_ENTRY_TIME_NS: u64 = 7 * NANOS_IN_SECOND; const GRACE_PERIOD_TIME_NS: u64 = 10 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND), - ..Default::default() - }; - let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_TIME_NS / NANOS_IN_SECOND) .build(); let first_code_hash = [1; 32]; @@ -641,12 +631,8 @@ fn latest_image_never_expires_if_its_not_superseded() { const START_TIME_SECONDS: u64 = 1; const GRACE_PERIOD_SECONDS: u64 = 10; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let only_image_code_hash = [123; 32]; @@ -700,12 +686,8 @@ fn nodes_can_start_with_old_valid_hashes_during_grace_period() { const GRACE_PERIOD_NANOS: u64 = GRACE_PERIOD_SECONDS * NANOS_IN_SECOND; const HASH_DEPLOYMENT_INTERVAL_NANOS: u64 = 3 * NANOS_IN_SECOND; - let init_config = near_mpc_contract_interface::types::InitConfig { - tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS), - ..Default::default() - }; let mut test_setup = TestSetupBuilder::new() - .with_init_config(init_config) + .with_tee_upgrade_grace_period_seconds(GRACE_PERIOD_SECONDS) .build(); let hash_v1 = [1; 32]; // Original version diff --git a/crates/contract/tests/sandbox/common.rs b/crates/contract/tests/sandbox/common.rs index 60aa02036b..bb8b7549ff 100644 --- a/crates/contract/tests/sandbox/common.rs +++ b/crates/contract/tests/sandbox/common.rs @@ -145,6 +145,7 @@ pub async fn init_contract_running( next_domain_id: u64, keyset: Keyset, params: ThresholdParameters, + init_config: Option, ) -> ExecutionSuccess { let result = contract .call(method_names::INIT_RUNNING) @@ -153,6 +154,7 @@ pub async fn init_contract_running( "next_domain_id": next_domain_id, "keyset": keyset, "parameters": params, + "init_config": init_config, })) .gas(GAS_FOR_INIT) .transact() @@ -311,6 +313,7 @@ impl SandboxTestSetupBuilder { next_domain_id, keyset, threshold_parameters, + self.init_config, ) .await; } else { 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/participants_gas.rs b/crates/contract/tests/sandbox/participants_gas.rs index bd747b99f2..f07e1a743a 100644 --- a/crates/contract/tests/sandbox/participants_gas.rs +++ b/crates/contract/tests/sandbox/participants_gas.rs @@ -289,7 +289,15 @@ async fn setup_test_env_with_state(n_participants: usize, running_state: bool) - let keyset = Keyset::new(EpochId::new(1), vec![key]); let domains = vec![domain]; let next_domain_id = domains.len() as u64 + 1; - init_contract_running(&contract, domains, next_domain_id, keyset, threshold_params).await; + init_contract_running( + &contract, + domains, + next_domain_id, + keyset, + threshold_params, + None, + ) + .await; } else { init_contract(&contract, threshold_params, None).await; } diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index d28ab3541d..b9df5231ca 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -8,7 +8,8 @@ use crate::sandbox::{ mpc_contract::{ assert_running_return_participants, assert_running_return_threshold, get_participant_attestation, get_state, get_tee_accounts, submit_participant_info, - vote_add_launcher_hash, vote_for_hash, + submit_participant_info_with_deposit, total_gas_fee, vote_add_launcher_hash, + vote_for_hash, }, resharing_utils::conclude_resharing, sign_utils::DomainResponseTest, @@ -267,13 +268,11 @@ async fn test_submit_participant_info_succeeds_with_mock_attestation() -> Result .with_protocols(ALL_PROTOCOLS) .build() .await; - let mock_attestation = Attestation::Mock(MockAttestation::Valid); - let tls_key = p2p_tls_key().into(); let success = submit_participant_info( &mpc_signer_accounts[0], &contract, - &mock_attestation, - &tls_key, + &Attestation::Mock(MockAttestation::Valid), + &p2p_tls_key().into(), ) .await? .is_success(); @@ -306,13 +305,10 @@ async fn test_clean_tee_status_denies_external_account_access() -> Result<()> { assert!(!result.is_success()); // Verify the error message indicates unauthorized access - match result.into_result() { - Err(failure) => { - let error_msg = format!("{:?}", failure); - assert!(error_msg.contains("Method clean_tee_status is private")); - } - Ok(_) => panic!("Call should have failed"), - } + let failure = result + .into_result() + .expect_err("clean_tee_status must reject a non-private caller"); + assert!(format!("{failure:?}").contains("Method clean_tee_status is private")); Ok(()) } @@ -1002,16 +998,14 @@ async fn submit_participant_info__should_reject_new_attestation_below_flat_fee() let below_fee = SUBMIT_PARTICIPANT_INFO_DEPOSIT.saturating_sub(NearToken::from_yoctonear(1)); // When - let result = outsider - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json(( - Attestation::Mock(MockAttestation::Valid), - fresh_tls_key.clone(), - )) - .deposit(below_fee) - .max_gas() - .transact() - .await?; + let result = submit_participant_info_with_deposit( + &outsider, + &contract, + &Attestation::Mock(MockAttestation::Valid), + &fresh_tls_key, + below_fee, + ) + .await?; // Then assert!( @@ -1054,16 +1048,13 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl let balance_before = outsider.view_account().await?.balance; // When - let result = outsider - .call(contract.id(), method_names::SUBMIT_PARTICIPANT_INFO) - .args_json(( - Attestation::Mock(MockAttestation::Valid), - fresh_tls_key.clone(), - )) - .deposit(SUBMIT_PARTICIPANT_INFO_DEPOSIT) - .max_gas() - .transact() - .await?; + let result = submit_participant_info( + &outsider, + &contract, + &Attestation::Mock(MockAttestation::Valid), + &fresh_tls_key, + ) + .await?; // Then assert!( @@ -1075,13 +1066,9 @@ async fn submit_participant_info__should_store_new_attestation_and_charge_the_fl stored.is_some(), "the attestation entry should be stored on-chain" ); - // The whole flat fee is consumed (no excess refund); `spent` also covers gas, - // so it must be at least the fee. let balance_after = outsider.view_account().await?.balance; - let spent = balance_before.saturating_sub(balance_after); - assert!( - spent >= SUBMIT_PARTICIPANT_INFO_DEPOSIT, - "caller must be charged the full flat fee ({SUBMIT_PARTICIPANT_INFO_DEPOSIT}), spent {spent}" - ); + let net_spent = balance_before.saturating_sub(balance_after); + let non_gas_spent = net_spent.saturating_sub(total_gas_fee(&result)); + assert_eq!(non_gas_spent, SUBMIT_PARTICIPANT_INFO_DEPOSIT); Ok(()) } diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs new file mode 100644 index 0000000000..6409e92905 --- /dev/null +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -0,0 +1,221 @@ +//! Sandbox tests for the async [`submit_participant_info`] flow, driving the real +//! `tee-verifier` (or no verifier): +//! - Rejected: real verifier with a malformed quote. +//! - Unavailable: a verifier account that was never deployed. +//! +//! The Verified verdict is covered in-process instead (`verify_and_store_dstack` under +//! a pinned clock): real `verify_quote` checks the quote against live block time, and the +//! sandbox clock can't be wound back to the fixture's validity window. +#![allow(non_snake_case)] + +use crate::sandbox::{ + common::SandboxTestSetup, + utils::{ + consts::{ALL_PROTOCOLS, SUBMIT_PARTICIPANT_INFO_DEPOSIT}, + contract_build::tee_verifier_contract, + mpc_contract::{ + get_participant_attestation, submit_participant_info, + submit_participant_info_with_deposit, total_gas_fee, vote_tee_verifier_change, + }, + }, +}; +use mpc_contract::errors::TeeError; +use near_mpc_contract_interface::types as dtos; +use near_workspaces::{ + Account, AccountId, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, + types::NearToken, +}; +use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; + +/// Deposit attached to a Dstack submission: the flat storage fee, consumed on +/// success and fully refunded on failure. +const SUBMIT_DEPOSIT: NearToken = SUBMIT_PARTICIPANT_INFO_DEPOSIT; + +async fn setup() -> SandboxTestSetup { + SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .build() + .await +} + +/// Votes `verifier` in as `mpc-contract`'s trusted verifier (all participants vote +/// so the change crosses threshold). +async fn trust_verifier(contract: &Contract, participants: &[Account], verifier: &AccountId) { + let expected_code_hash = [7u8; 32]; + for account in participants { + vote_tee_verifier_change(account, contract, verifier, expected_code_hash) + .await + .unwrap(); + } +} + +async fn deploy_and_trust_verifier( + worker: &Worker, + contract: &Contract, + participants: &[Account], +) { + let verifier = worker.dev_deploy(tee_verifier_contract()).await.unwrap(); + trust_verifier(contract, participants, verifier.id()).await; +} + +async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { + submit_participant_info_with_deposit( + submitter, + contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap() +} + +/// Asserts a Dstack submission failed cleanly: a receipt failed carrying +/// `expected_error` (`fail_attestation_submission` panics in its own receipt), no +/// attestation was stored, and the deposit was refunded. +async fn assert_submission_failed_cleanly( + result: &ExecutionFinalResult, + contract: &Contract, + submitter: &Account, + balance_before: NearToken, + expected_error: &TeeError, +) { + let failures = result.failures(); + assert!( + !failures.is_empty(), + "expected the promise chain to fail on a receipt, got: {result:#?}" + ); + // Substring-match: near-workspaces keeps `ExecutionOutcome.status` + // `pub(crate)`, so the error is only reachable via the Debug dump. + let rendered = format!("{failures:?}"); + let expected = expected_error.to_string(); + assert!( + rendered.contains(&expected), + "expected a receipt failure containing {expected:?}, got: {rendered}" + ); + + let stored = get_participant_attestation(contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "nothing should be stored on failure"); + assert_deposit_refunded(submitter, balance_before, result).await; +} + +/// Asserts the deposit was fully refunded: with nothing stored, the caller spends only gas. +async fn assert_deposit_refunded( + account: &Account, + balance_before: NearToken, + result: &ExecutionFinalResult, +) { + let balance_after = account.view_account().await.unwrap().balance; + let net_spent = balance_before.saturating_sub(balance_after); + assert_eq!(net_spent, total_gas_fee(result)); +} + +#[tokio::test] +async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { + // Given + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = setup().await; + + // When + let result = submit_participant_info( + &mpc_signer_accounts[0], + &contract, + &mock_dto_dstack_attestation(), + &p2p_tls_key().into(), + ) + .await + .unwrap(); + + // Then: it fails synchronously (before any cross-contract call), so the error + // is on the top-level tx result, not a later receipt. + let err = result + .into_result() + .expect_err("Dstack submit must fail when no verifier is configured") + .to_string(); + let expected_panic = format!( + "Smart contract panicked: {}", + TeeError::VerifierNotConfigured + ); + assert!( + err.contains(&expected_panic), + "expected {expected_panic:?}, got: {err}" + ); + let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) + .await + .unwrap(); + assert!(stored.is_none(), "no attestation should be stored"); +} + +#[tokio::test] +async fn submit_participant_info__should_refund_and_store_nothing_on_verifier_rejection() { + // Given + let SandboxTestSetup { + worker, + mpc_signer_accounts, + contract, + .. + } = setup().await; + deploy_and_trust_verifier(&worker, &contract, &mpc_signer_accounts).await; + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; + let mut attestation = mock_dto_dstack_attestation(); + let dtos::Attestation::Dstack(dstack) = &mut attestation else { + panic!("fixture must be a Dstack attestation"); + }; + dstack.quote = dtos::HexVec(vec![0u8; 16]); + + // When + let result = submit_participant_info_with_deposit( + &submitter, + &contract, + &attestation, + &p2p_tls_key().into(), + SUBMIT_DEPOSIT, + ) + .await + .unwrap(); + + // Then + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::QuoteRejected { + reason: String::new(), + }, + ) + .await; +} + +#[tokio::test] +async fn submit_participant_info__should_fail_and_store_nothing_when_verifier_unreachable() { + // Given: a verifier account that was never deployed, so the verify_quote promise fails. + let SandboxTestSetup { + mpc_signer_accounts, + contract, + .. + } = setup().await; + let missing_verifier: AccountId = "nonexistent-verifier.near".parse().unwrap(); + trust_verifier(&contract, &mpc_signer_accounts, &missing_verifier).await; + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = submitter.view_account().await.unwrap().balance; + + // When + let result = submit_dstack(&submitter, &contract).await; + + // Then + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &TeeError::VerifierUnavailable, + ) + .await; +} diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index cdaedf6e4d..f99c3f154b 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 TEE_VERIFIER_MANIFEST: &str = "crates/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 TEE_VERIFIER_CONTRACT: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -54,3 +56,7 @@ 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 tee_verifier_contract() -> &'static [u8] { + TEE_VERIFIER_CONTRACT.get_or_init(|| ContractBuilder::new(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 8e5aa06c80..5006c2dacd 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -3,12 +3,22 @@ use std::collections::BTreeSet; use super::consts::SUBMIT_PARTICIPANT_INFO_DEPOSIT; 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, Contract, result::ExecutionFinalResult}; +use near_workspaces::{ + Account, AccountId, Contract, result::ExecutionFinalResult, types::NearToken, +}; + +pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken { + result + .outcomes() + .iter() + .map(|outcome| outcome.tokens_burnt) + .fold(NearToken::from_yoctonear(0), NearToken::saturating_add) +} pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract @@ -41,22 +51,55 @@ 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, + SUBMIT_PARTICIPANT_INFO_DEPOSIT, + ) + .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(SUBMIT_PARTICIPANT_INFO_DEPOSIT) + .deposit(deposit) .max_gas() .transact() - .await?; - dbg!(&result); - Ok(result) + .await?) +} + +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/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index 752496fdb2..4a95134e74 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -94,7 +94,9 @@ impl AcceptedAttestation { } #[expect(clippy::large_enum_variant)] -#[derive(Debug, Default, Clone, Serialize, Deserialize, BorshDeserialize, BorshSerialize)] +#[derive( + Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, BorshDeserialize, BorshSerialize, +)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(borsh::BorshSchema) diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 340e26f674..94ee48200c 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -7,13 +7,14 @@ edition = { workspace = true } [dependencies] cargo-near-build = { workspace = true } hex = { workspace = true } -mpc-attestation = { workspace = true, features = ["test-utils"] } +mpc-attestation = { workspace = true, features = ["test-utils", "local-verify"] } mpc-primitives = { workspace = true } near-mpc-contract-interface = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } serde_json = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } +tee-verifier-interface = { workspace = true } [lints] workspace = true diff --git a/crates/test-utils/src/attestation.rs b/crates/test-utils/src/attestation.rs index 3c4af6c72e..9898fde392 100644 --- a/crates/test-utils/src/attestation.rs +++ b/crates/test-utils/src/attestation.rs @@ -7,8 +7,10 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma use near_mpc_contract_interface::types::HexVec; use serde_json::Value; use sha2::{Digest, Sha256}; +use tee_verifier_interface::VerifiedReport; pub const TEST_TCB_INFO_STRING: &str = include_str!("../assets/tcb_info.json"); +pub const TEST_COLLATERAL_STRING: &str = include_str!("../assets/collateral.json"); pub const TEST_APP_COMPOSE_STRING: &str = include_str!("../assets/app_compose.json"); pub const TEST_APP_COMPOSE_WITH_SERVICES_STRING: &str = include_str!("../assets/app_compose_with_services.json"); @@ -58,8 +60,7 @@ pub fn image_digest() -> NodeImageHash { } pub fn collateral() -> Value { - let quote_collateral_json_string = include_str!("../assets/collateral.json"); - quote_collateral_json_string + TEST_COLLATERAL_STRING .parse() .expect("Quote collateral file is a valid json.") } @@ -96,21 +97,30 @@ pub fn near_account_key() -> near_sdk::PublicKey { key_file.parse().expect("File contains a valid public key") } -pub fn mock_dstack_attestation() -> Attestation { +pub fn mock_dstack_attestation_inner() -> DstackAttestation { let quote = quote(); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = mpc_attestation::collateral::collateral_from_str(collateral_json_string) + let collateral = mpc_attestation::collateral::collateral_from_str(TEST_COLLATERAL_STRING) .expect("collateral.json is valid collateral"); - let tcb_info: TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap(); + DstackAttestation::new(quote, collateral, tcb_info) +} + +pub fn mock_dstack_attestation() -> Attestation { + Attestation::Dstack(mock_dstack_attestation_inner()) +} - Attestation::Dstack(DstackAttestation::new(quote, collateral, tcb_info)) +/// The [`VerifiedReport`] the real `tee-verifier` would return for the fixture +/// quote, produced by running the DCAP step at [`VALID_ATTESTATION_TIMESTAMP`] +/// (when the fixture collateral is valid). +pub fn verified_report() -> VerifiedReport { + mock_dstack_attestation_inner() + .verify_dcap_quote(VALID_ATTESTATION_TIMESTAMP) + .expect("fixture quote verifies at VALID_ATTESTATION_TIMESTAMP") } pub fn mock_dto_dstack_attestation() -> near_mpc_contract_interface::types::Attestation { let quote = HexVec::from(Vec::from(quote())); - let collateral_json_string = include_str!("../assets/collateral.json"); - let collateral = serde_json::from_str(collateral_json_string).unwrap(); + let collateral = serde_json::from_str(TEST_COLLATERAL_STRING).unwrap(); let tcb_info: near_mpc_contract_interface::types::TcbInfo = serde_json::from_str(TEST_TCB_INFO_STRING).unwrap();