diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 686a0ab241..430cf6b4cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,6 +295,9 @@ jobs: if: github.ref == 'refs/heads/main' run: nix develop --command cargo near build reproducible-wasm --manifest-path crates/contract/Cargo.toml + - name: Build tee-verifier + run: nix develop --command cargo make build-tee-verifier-optimized + - name: Build test-parallel-contract run: nix develop --command cargo make build-test-parallel-contract-optimized diff --git a/Cargo.toml b/Cargo.toml index 316d45e8be..8e0d067d3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -308,6 +308,7 @@ time = "0.3.45" [workspace.lints.rust] unexpected_cfgs = { level = "allow", check-cfg = [ 'cfg(feature, values("abi"))', + 'cfg(mpc_sandbox_wasm)', ] } [workspace.lints.clippy] diff --git a/Makefile.toml b/Makefile.toml index fc24893556..b55ad80207 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -165,7 +165,7 @@ args = ["scripts/check-sandbox-image-version.sh"] # These build tasks are the single source of truth for both local and CI builds. # CI's `mpc-e2e-tests` job invokes them via `cargo make`. -# All three are skipped when `E2E_SKIP_BUILD` is set (used by `e2e-tests-skip-build`). +# Each is skipped when `E2E_SKIP_BUILD` is set (used by `e2e-tests-skip-build`). [tasks.build-mpc-node-network-hardship-simulation] description = "Build the mpc-node binary used by the E2E tests" @@ -202,7 +202,8 @@ args = [ ] [tasks.build-tee-verifier-optimized] -description = "Build the tee-verifier WASM for localnet" +description = "Build the tee-verifier WASM for localnet and the E2E tests" +condition = { env_not_set = ["E2E_SKIP_BUILD"] } command = "cargo" args = [ "near", @@ -252,6 +253,7 @@ private = true dependencies = [ "build-mpc-node-network-hardship-simulation", "build-mpc-contract-optimized", + "build-tee-verifier-optimized", "build-test-parallel-contract-optimized", "build-backup-cli", ] @@ -272,8 +274,9 @@ args = [ [tasks._run-e2e-logic.env] MPC_CONTRACT_WASM = "${CARGO_MAKE_WORKING_DIRECTORY}/target/near/mpc_contract/mpc_contract.wasm" MPC_PARALLEL_CONTRACT_WASM = "${CARGO_MAKE_WORKING_DIRECTORY}/target/near/test_parallel_contract/test_parallel_contract.wasm" +MPC_TEE_VERIFIER_WASM = "${CARGO_MAKE_WORKING_DIRECTORY}/target/near/tee_verifier/tee_verifier.wasm" -# Build the mpc-node binary and both contract WASMs, then run the E2E tests. +# Build the mpc-node binary, the contract WASMs, and the backup CLI, then run the E2E tests. [tasks.e2e-tests] description = "Build required binaries and run the E2E tests" run_task = "_run-e2e-logic" diff --git a/crates/contract/src/tee/proposal.rs b/crates/contract/src/tee/proposal.rs index a1c7ae0355..9253cacf11 100644 --- a/crates/contract/src/tee/proposal.rs +++ b/crates/contract/src/tee/proposal.rs @@ -491,7 +491,7 @@ impl AllowedLauncherImages { /// Test-only: allows one more compose hash for an already-allowed launcher. The attestation /// fixture is captured from a CVM whose launcher compose carries a key-export service, so /// [`get_docker_compose_hash`] cannot derive its hash. - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] pub(crate) fn allow_compose_hash( &mut self, launcher_hash: &LauncherImageHash, diff --git a/crates/contract/src/tee/test_utils.rs b/crates/contract/src/tee/test_utils.rs index 23e2d28908..aa23e7ea5e 100644 --- a/crates/contract/src/tee/test_utils.rs +++ b/crates/contract/src/tee/test_utils.rs @@ -3,11 +3,13 @@ //! This module provides helper functions and types for testing TEE state, //! attestation behavior, and general contract state management. +use crate::MpcContract; 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 mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeImageHash}; use near_account_id::AccountId; +use near_sdk::borsh::{self, BorshDeserialize}; use near_sdk::{BlockHeight, NearToken, PublicKey, test_utils::VMContextBuilder, testing_env}; use rand::Rng; use std::time::Duration; @@ -115,3 +117,19 @@ pub fn whitelist_dstack_measurements( tee_state.add_measurement(ContractExpectedMeasurements::from(measurements)); } } + +/// Adds a [`LauncherDockerComposeHash`] to a [`LauncherImageHash`]'s allowlist entry in a +/// raw `STATE` blob, for sandbox tests to patch back in. The attestation fixture's compose +/// hash is not derivable from the compiled-in template, so no vote can allow it. +pub fn allow_launcher_compose_hash_in_state( + state: &[u8], + launcher_hash: &LauncherImageHash, + compose_hash: LauncherDockerComposeHash, +) -> Vec { + let mut contract = MpcContract::try_from_slice(state).expect("STATE deserializes"); + contract + .tee_state + .allowed_launcher_images + .allow_compose_hash(launcher_hash, compose_hash); + borsh::to_vec(&contract).expect("STATE serializes") +} diff --git a/crates/contract/tests/sandbox/tee.rs b/crates/contract/tests/sandbox/tee.rs index 83ffecad61..e346323435 100644 --- a/crates/contract/tests/sandbox/tee.rs +++ b/crates/contract/tests/sandbox/tee.rs @@ -9,7 +9,7 @@ use crate::sandbox::{ consts::ALL_PROTOCOLS, interface::IntoContractType, mpc_contract::{ - assert_running_return_participants, assert_running_return_threshold, + assert_running_return_participants, assert_running_return_threshold, get_config, get_participant_attestation, get_state, get_tee_accounts, prepay_and_submit_participant_info, prepay_attestation_grants, submit_participant_info, vote_add_launcher_hash, vote_for_hash, @@ -25,7 +25,7 @@ use mpc_primitives::hash::{LauncherDockerComposeHash, LauncherImageHash, NodeIma use near_mpc_contract_interface::deposits::STORAGE_BYTE_COST_YOCTONEAR; use near_mpc_contract_interface::method_names; use near_mpc_contract_interface::types::{ - self as dtos, Attestation, Config, MockAttestation, Protocol, + self as dtos, Attestation, MockAttestation, Protocol, VerifiedAttestation, }; use near_workspaces::types::{KeyType, NearToken, SecretKey}; use near_workspaces::{AccessKey, Account, Contract}; @@ -595,10 +595,9 @@ async fn get_attestation_returns_none_when_tls_key_is_not_associated_with_an_att assert!(validation_success); - let attestation_for_tls_key_2: Option = - get_participant_attestation(&contract, &tls_key_2) - .await - .unwrap(); + let attestation_for_tls_key_2 = get_participant_attestation(&contract, &tls_key_2) + .await + .unwrap(); assert_eq!(attestation_for_tls_key_2, None); } @@ -637,12 +636,13 @@ async fn get_attestation_returns_some_when_tls_key_associated_with_an_attestatio expected_measurements: None, }); - let participant_2_attestation = Attestation::Mock(MockAttestation::WithConstraints { + let participant_2_mock = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(now_seconds + 2_000), expected_measurements: None, - }); + }; + let participant_2_attestation = Attestation::Mock(participant_2_mock.clone()); assert_ne!( participant_1_attestation, participant_2_attestation, @@ -671,12 +671,14 @@ async fn get_attestation_returns_some_when_tls_key_associated_with_an_attestatio .is_success(); assert!(validation_success, "Submitting attestation failed."); - let attestation_for_tls_key_2: Option = - get_participant_attestation(&contract, &tls_key_2) - .await - .unwrap(); + let attestation_for_tls_key_2 = get_participant_attestation(&contract, &tls_key_2) + .await + .unwrap(); - assert_eq!(attestation_for_tls_key_2, Some(participant_2_attestation)); + assert_eq!( + attestation_for_tls_key_2, + Some(VerifiedAttestation::Mock(participant_2_mock)) + ); } #[tokio::test] @@ -705,12 +707,13 @@ async fn get_attestation_overwrites_when_same_tls_key_is_reused() { expected_measurements: None, }); - let second_attestation = Attestation::Mock(MockAttestation::WithConstraints { + let second_mock = MockAttestation::WithConstraints { mpc_docker_image_hash: None, launcher_docker_compose_hash: None, expiry_timestamp_seconds: Some(now_seconds + 2_000), expected_measurements: None, - }); + }; + let second_attestation = Attestation::Mock(second_mock.clone()); assert_ne!( first_attestation, second_attestation, @@ -742,14 +745,13 @@ async fn get_attestation_overwrites_when_same_tls_key_is_reused() { assert!(validation_success, "Second attestation submission failed"); // Now the latest attestation should be returned - let attestation_for_tls_key: Option = - get_participant_attestation(&contract, &tls_key) - .await - .unwrap(); + let attestation_for_tls_key = get_participant_attestation(&contract, &tls_key) + .await + .unwrap(); assert_eq!( attestation_for_tls_key, - Some(second_attestation), + Some(VerifiedAttestation::Mock(second_mock)), "Expected the second attestation to overwrite the first for the same TLS key" ); } @@ -1088,11 +1090,7 @@ async fn prepay_and_submit_a_constrained_mock__should_use_at_most_half_a_grant_f }); let node = worker.dev_create_account().await?; let tls_key = bogus_ed25519_public_key(); - let config: Config = contract - .view(method_names::CONFIG) - .args_json(serde_json::json!({})) - .await? - .json()?; + let config = get_config(&contract).await?; let before = contract.as_account().view_account().await?; // When diff --git a/crates/contract/tests/sandbox/tee_verifier.rs b/crates/contract/tests/sandbox/tee_verifier.rs index 611e801da7..895d82bd86 100644 --- a/crates/contract/tests/sandbox/tee_verifier.rs +++ b/crates/contract/tests/sandbox/tee_verifier.rs @@ -1,49 +1,68 @@ -//! 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. +//! Sandbox tests for the async [`submit_participant_info`] flow against a real +//! deployed `tee-verifier`, covering the Rejected, Unavailable, and Verified +//! verdicts. Verified needs the `sandbox-test-hooks` verifier with its clock +//! pinned inside the fixture collateral's validity window, the fixture's +//! compose hash patched into contract state (no vote can derive it), and +//! signing as the fixture account, whose key the quote's report_data binds. #![allow(non_snake_case)] use crate::sandbox::{ common::SandboxTestSetup, utils::{ consts::ALL_PROTOCOLS, - contract_build::tee_verifier_contract, + contract_build::{tee_verifier_contract, tee_verifier_contract_with_sandbox_test_hooks}, mpc_contract::{ - get_participant_attestation, prepay_and_submit_participant_info, - prepay_attestation_grants, submit_participant_info, tee_verifier_account_id, - total_gas_fee, vote_tee_verifier_change, + get_config, get_participant_attestation, get_tee_accounts, prepay_attestation_grants, + submit_participant_info, tee_verifier_account_id, total_gas_fee, + vote_add_launcher_hash, vote_add_os_measurement, vote_for_hash, + vote_tee_verifier_change, }, }, }; -use mpc_contract::errors::TeeError; -use near_mpc_contract_interface::types as dtos; +use attestation::measurements::Measurements; +use futures::future::join_all; +use mpc_attestation::attestation::{DEFAULT_EXPIRATION_DURATION_SECONDS, default_measurements}; +use mpc_contract::{ + errors::TeeError, + tee::{ + measurements::ContractExpectedMeasurements, tee_state::AttestationSubmissionError, + test_utils::allow_launcher_compose_hash_in_state, + }, +}; +use near_mpc_contract_interface::{method_names, types as dtos}; use near_workspaces::{ - Account, AccountId, Contract, Worker, network::Sandbox, result::ExecutionFinalResult, - types::NearToken, + Account, AccountId, Contract, Worker, + network::Sandbox, + result::ExecutionFinalResult, + types::{Gas, NearToken, SecretKey}, +}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tee_verifier_interface::SANDBOX_TEST_PINNED_NOW_STORAGE_KEY; +use test_utils::attestation::{ + VALID_ATTESTATION_TIMESTAMP, account_secret_key, image_digest, launcher_compose_digest, + launcher_image_hash, mock_dto_dstack_attestation, p2p_tls_key, verified_report, }; -use test_utils::attestation::{mock_dto_dstack_attestation, p2p_tls_key}; -async fn setup() -> SandboxTestSetup { - SandboxTestSetup::builder() +async fn setup() -> (Worker, Contract, Vec) { + let setup = SandboxTestSetup::builder() .with_protocols(ALL_PROTOCOLS) .build() - .await + .await; + (setup.worker, setup.contract, setup.mpc_signer_accounts) +} + +async fn all_vote(participants: &[Account], vote: impl AsyncFn(&Account) -> anyhow::Result<()>) { + for result in join_all(participants.iter().map(|p| vote(p))).await { + result.unwrap(); + } } -/// 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(); - } + all_vote(participants, async |account| { + vote_tee_verifier_change(account, contract, verifier, expected_code_hash).await + }) + .await; } async fn deploy_and_trust_verifier( @@ -55,6 +74,123 @@ async fn deploy_and_trust_verifier( trust_verifier(contract, participants, verifier.id()).await; } +async fn pin_verifier_clock(worker: &Worker, verifier: &AccountId, pin: u64) { + worker + .patch_state( + verifier, + SANDBOX_TEST_PINNED_NOW_STORAGE_KEY, + &pin.to_le_bytes(), + ) + .await + .unwrap(); +} + +async fn deploy_and_trust_pinned_verifier( + worker: &Worker, + contract: &Contract, + participants: &[Account], +) -> Contract { + let verifier = worker + .dev_deploy(tee_verifier_contract_with_sandbox_test_hooks()) + .await + .unwrap(); + pin_verifier_clock(worker, verifier.id(), VALID_ATTESTATION_TIMESTAMP).await; + trust_verifier(contract, participants, verifier.id()).await; + verifier +} + +async fn vote_fixture_image_and_launcher(contract: &Contract, participants: &[Account]) { + let image = image_digest(); + all_vote(participants, async |account| { + vote_for_hash(account, contract, &image).await + }) + .await; + let launcher = launcher_image_hash(); + all_vote(participants, async |account| { + vote_add_launcher_hash(account, contract, &launcher).await + }) + .await; +} + +/// Votes the fixture's image and launcher hashes in, then patches its compose hash into +/// contract state. The contract only allows compose hashes computed from its compiled-in +/// template; the fixture CVM's compose carried an extra key-export service, so no vote +/// can ever allow its hash. +async fn whitelist_fixture_dstack_hashes( + worker: &Worker, + contract: &Contract, + participants: &[Account], +) { + vote_fixture_image_and_launcher(contract, participants).await; + let mut entries = worker + .view_state(contract.id()) + .prefix(b"STATE") + .await + .unwrap(); + let state = entries + .remove(b"STATE".as_slice()) + .expect("the contract must have a STATE entry"); + let patched = allow_launcher_compose_hash_in_state( + &state, + &launcher_image_hash(), + launcher_compose_digest(), + ); + worker + .patch_state(contract.id(), b"STATE", &patched) + .await + .unwrap(); +} + +async fn create_fixture_account(worker: &Worker, account_id: &str) -> Account { + let secret_key: SecretKey = account_secret_key() + .parse() + .expect("near_account_secret_key asset holds a valid ed25519 secret key"); + worker + .create_root_account_subaccount(account_id.parse().unwrap(), secret_key) + .await + .unwrap() + .into_result() + .unwrap() +} + +fn wall_clock_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +/// A separate payer leaves the beneficiary's balance untouched, so tests can assert it +/// spent only gas. Returns the beneficiary's balance after the prepayment. +async fn prepay_grant_from_separate_payer( + worker: &Worker, + contract: &Contract, + beneficiary: &Account, +) -> NearToken { + let payer = worker.dev_create_account().await.unwrap(); + let prepayment = prepay_attestation_grants(&payer, contract, beneficiary.id(), 1) + .await + .unwrap(); + assert!(prepayment.is_success(), "prepayment failed: {prepayment:?}"); + beneficiary.view_account().await.unwrap().balance +} + +async fn setup_verified_fixture() -> (Worker, Contract, Account) { + let (worker, contract, mpc_signer_accounts) = setup().await; + deploy_and_trust_pinned_verifier(&worker, &contract, &mpc_signer_accounts).await; + whitelist_fixture_dstack_hashes(&worker, &contract, &mpc_signer_accounts).await; + for &measurements in default_measurements() { + let measurements = ContractExpectedMeasurements::from(measurements); + all_vote(&mpc_signer_accounts, async |account| { + vote_add_os_measurement(account, &contract, &measurements).await + }) + .await; + } + let submitter = create_fixture_account(&worker, "fixture-node-a").await; + prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; + (worker, contract, submitter) +} + async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFinalResult { submit_participant_info( submitter, @@ -66,15 +202,21 @@ async fn submit_dstack(submitter: &Account, contract: &Contract) -> ExecutionFin .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 caller spent only gas. +async fn stored_fixture_attestation(contract: &Contract) -> Option { + get_participant_attestation(contract, &p2p_tls_key().into()) + .await + .unwrap() +} + +/// Asserts a Dstack submission failed cleanly: a receipt failed mentioning every +/// string in `expected_error` (`fail_attestation_submission` panics in its own +/// receipt), no attestation was stored, and the caller spent only gas. async fn assert_submission_failed_cleanly( result: &ExecutionFinalResult, contract: &Contract, submitter: &Account, balance_before: NearToken, - expected_error: &TeeError, + expected_error: &[&str], ) { let failures = result.failures(); assert!( @@ -84,49 +226,62 @@ async fn assert_submission_failed_cleanly( // 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}" - ); + for expected in expected_error { + assert!( + rendered.contains(expected), + "expected a receipt failure containing {expected:?}, got: {rendered}" + ); + } - let stored = get_participant_attestation(contract, &p2p_tls_key().into()) - .await - .unwrap(); + let stored = stored_fixture_attestation(contract).await; assert!(stored.is_none(), "nothing should be stored on failure"); assert_only_gas_spent(submitter, balance_before, result).await; } +/// Calls `verify_quote` with the committed borsh argument fixture and returns the raw +/// borsh return value, asserting the call itself succeeded. +async fn call_verify_quote(verifier: &Contract, args: &[u8]) -> Vec { + let result = verifier + .call(method_names::VERIFY_QUOTE) + .args(args.to_vec()) + .max_gas() + .transact() + .await + .unwrap(); + assert!(result.is_success(), "verify_quote failed: {result:#?}"); + result.raw_bytes().unwrap() +} + /// Asserts the caller spent only gas: no deposit is attached, so a failed submission costs nothing -/// beyond gas. +/// beyond gas. The unspent-gas refund lands a block or two after the transaction, so poll until +/// the balance settles instead of reading it once. async fn assert_only_gas_spent( 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)); + let expected = total_gas_fee(result); + let mut net_spent = balance_before; + for _ in 0..20 { + let balance_after = account.view_account().await.unwrap().balance; + net_spent = balance_before.saturating_sub(balance_after); + if net_spent == expected { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + assert_eq!(net_spent, expected); } #[tokio::test] async fn submit_participant_info__should_reject_dstack_when_verifier_not_configured() { // Given - let SandboxTestSetup { - mpc_signer_accounts, - contract, - .. - } = setup().await; + let (worker, contract, mpc_signer_accounts) = setup().await; + let submitter = &mpc_signer_accounts[0]; + prepay_grant_from_separate_payer(&worker, &contract, submitter).await; // When - let result = prepay_and_submit_participant_info( - &mpc_signer_accounts[0], - &contract, - &mock_dto_dstack_attestation(), - &p2p_tls_key().into(), - ) - .await - .unwrap(); + let result = submit_dstack(submitter, &contract).await; // Then: it fails synchronously (before any cross-contract call), so the error // is on the top-level tx result, not a later receipt. @@ -142,20 +297,14 @@ async fn submit_participant_info__should_reject_dstack_when_verifier_not_configu err.contains(&expected_panic), "expected {expected_panic:?}, got: {err}" ); - let stored = get_participant_attestation(&contract, &p2p_tls_key().into()) - .await - .unwrap(); + let stored = stored_fixture_attestation(&contract).await; assert!(stored.is_none(), "no attestation should be stored"); } #[tokio::test] async fn tee_verifier_account_id__should_return_none_until_a_verifier_is_voted_in() { // Given - let SandboxTestSetup { - mpc_signer_accounts, - contract, - .. - } = setup().await; + let (_worker, contract, mpc_signer_accounts) = setup().await; assert_eq!(tee_verifier_account_id(&contract).await, None); // When @@ -169,23 +318,10 @@ async fn tee_verifier_account_id__should_return_none_until_a_verifier_is_voted_i #[tokio::test] async fn submit_participant_info__should_store_nothing_on_verifier_rejection() { // Given - let SandboxTestSetup { - worker, - mpc_signer_accounts, - contract, - .. - } = setup().await; + let (worker, contract, mpc_signer_accounts) = setup().await; deploy_and_trust_verifier(&worker, &contract, &mpc_signer_accounts).await; let submitter = mpc_signer_accounts[0].clone(); - // A separate account funds the grant, exactly as an operator does for a node. Keeping the - // payer distinct leaves the submitter's balance untouched by the prepayment, so the - // assertion below is about the failed submission alone: it must cost nothing but gas. - let payer = worker.dev_create_account().await.unwrap(); - let prepayment = prepay_attestation_grants(&payer, &contract, submitter.id(), 1) - .await - .unwrap(); - assert!(prepayment.is_success(), "prepayment failed: {prepayment:?}"); - let balance_before = submitter.view_account().await.unwrap().balance; + let balance_before = prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; let mut attestation = mock_dto_dstack_attestation(); let dtos::Attestation::Dstack(dstack) = &mut attestation else { panic!("fixture must be a Dstack attestation"); @@ -204,9 +340,10 @@ async fn submit_participant_info__should_store_nothing_on_verifier_rejection() { &contract, &submitter, balance_before, - &TeeError::QuoteRejected { + &[&TeeError::QuoteRejected { reason: String::new(), - }, + } + .to_string()], ) .await; } @@ -214,24 +351,233 @@ async fn submit_participant_info__should_store_nothing_on_verifier_rejection() { #[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 (worker, contract, mpc_signer_accounts) = 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 = prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; + + // When + let result = submit_dstack(&submitter, &contract).await; + + // Then + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &[&TeeError::VerifierUnavailable.to_string()], + ) + .await; +} + +/// Tolerance for comparing an on-chain expiry stamp against this process's +/// wall clock (sandbox block time tracks it loosely). +const EXPIRY_SLACK_SECONDS: u64 = 600; + +#[tokio::test] +async fn submit_participant_info__should_run_dcap_within_verifier_gas_budget() { + // Given + let (worker, contract, mpc_signer_accounts) = setup().await; + let verifier = deploy_and_trust_pinned_verifier(&worker, &contract, &mpc_signer_accounts).await; + // Only the hash allowlists gate this test's outcome: the plain dev-account + // submitter fails at report_data, which runs before the measurements check. + whitelist_fixture_dstack_hashes(&worker, &contract, &mpc_signer_accounts).await; + let submitter = mpc_signer_accounts[0].clone(); + let balance_before = prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; + + // When + let result = submit_dstack(&submitter, &contract).await; + + // Then: the real DCAP run succeeds within the production gas budget and its + // Verified verdict reaches the callback. This test deliberately submits from + // a plain dev account rather than the fixture node, so it then fails at the + // post-DCAP report_data binding; asserting that terminal error pins that the + // verdict was Verified, not Rejected. + let outcomes = result.outcomes(); + let verify_quote_outcome = outcomes + .iter() + .find(|outcome| outcome.executor_id == *verifier.id()) + .expect("the verify_quote receipt must have executed on the verifier"); + assert!( + verify_quote_outcome.is_success(), + "verify_quote must succeed, got: {verify_quote_outcome:#?}" + ); + // The receipt is created with exactly `verifier_tera_gas` of static gas, so + // succeeding already proves it fit the budget. Assert headroom instead, which + // is the regression that matters: `dcap-qvl` growing until it OOGs in + // production. Read the budget from the contract so it cannot drift from + // `DEFAULT_VERIFIER_TERA_GAS`. + let budget = Gas::from_tgas(get_config(&contract).await.unwrap().verifier_tera_gas); + let headroom = Gas::from_gas(budget.as_gas() / 10); + assert!( + verify_quote_outcome.gas_burnt <= budget.saturating_sub(headroom), + "verify_quote burnt {} of the configured {budget}, leaving less than the {headroom} \ + headroom this test exists to protect. Raise DEFAULT_VERIFIER_TERA_GAS (config.rs) \ + before the real cost reaches the budget", + verify_quote_outcome.gas_burnt, + ); + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + // Substrings that survive the error's Debug formatting and + // near-workspaces' quote escaping. + &["failed verification", "report_data"], + ) + .await; +} + +#[tokio::test] +async fn submit_participant_info__should_fail_cleanly_when_verifier_gas_budget_too_low() { + // Given: a verifier gas budget far below the ~170 Tgas a real DCAP run + // needs, so the verify_quote receipt runs out of gas. let SandboxTestSetup { worker, mpc_signer_accounts, contract, .. - } = setup().await; - let missing_verifier: AccountId = "nonexistent-verifier.near".parse().unwrap(); - trust_verifier(&contract, &mpc_signer_accounts, &missing_verifier).await; + } = SandboxTestSetup::builder() + .with_protocols(ALL_PROTOCOLS) + .with_init_config(dtos::InitConfig { + verifier_tera_gas: Some(10), + ..Default::default() + }) + .build() + .await; + deploy_and_trust_verifier(&worker, &contract, &mpc_signer_accounts).await; let submitter = mpc_signer_accounts[0].clone(); - // A separate account funds the grant, exactly as an operator does for a node. Keeping the - // payer distinct leaves the submitter's balance untouched by the prepayment, so the - // assertion below is about the failed submission alone: it must cost nothing but gas. - let payer = worker.dev_create_account().await.unwrap(); - let prepayment = prepay_attestation_grants(&payer, &contract, submitter.id(), 1) - .await - .unwrap(); - assert!(prepayment.is_success(), "prepayment failed: {prepayment:?}"); + let balance_before = prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; + + // When + let result = submit_dstack(&submitter, &contract).await; + + // Then: the failed promise is indistinguishable from a crashed verifier. + assert_submission_failed_cleanly( + &result, + &contract, + &submitter, + balance_before, + &[&TeeError::VerifierUnavailable.to_string()], + ) + .await; +} + +#[tokio::test] +async fn submit_participant_info__should_store_attestation_on_verified_quote() { + // Given + let (_worker, contract, submitter) = setup_verified_fixture().await; + let contract = &contract; let balance_before = submitter.view_account().await.unwrap().balance; + let submitted_at = wall_clock_seconds(); + + // When + let result = submit_dstack(&submitter, contract).await; + + // Then + assert!( + result.failures().is_empty(), + "expected every receipt to succeed, got: {result:#?}" + ); + let stored = stored_fixture_attestation(contract) + .await + .expect("a Verified submission must store an attestation"); + let dtos::VerifiedAttestation::Dstack(stored) = stored else { + panic!("expected a stored Dstack attestation, got: {stored:?}"); + }; + let expected_expiry = submitted_at + DEFAULT_EXPIRATION_DURATION_SECONDS; + assert!( + stored.expiry_timestamp_seconds.abs_diff(expected_expiry) < EXPIRY_SLACK_SECONDS, + "expiry {} should be about {expected_expiry} (submission time + default expiration)", + stored.expiry_timestamp_seconds, + ); + // The stored measurements are the allowlist entry the fixture matched; select + // the expected entry by the fixture report's rtmrs, so the expectation stays + // independent of what was stored. + let fixture_rtmrs = + Measurements::try_from(verified_report()).expect("fixture quote carries a TD report"); + let matched = default_measurements() + .iter() + .find(|m| m.rtmrs == fixture_rtmrs) + .expect("the fixture's rtmrs must match one of the shipped measurement sets"); + let expected = dtos::VerifiedDstackAttestation { + mpc_image_hash: image_digest(), + launcher_compose_hash: launcher_compose_digest(), + expiry_timestamp_seconds: stored.expiry_timestamp_seconds, + measurements: dtos::VerifiedMeasurements { + mrtd: matched.rtmrs.mrtd.into(), + rtmr0: matched.rtmrs.rtmr0.into(), + rtmr1: matched.rtmrs.rtmr1.into(), + rtmr2: matched.rtmrs.rtmr2.into(), + key_provider_event_digest: matched.key_provider_event_digest.into(), + }, + }; + assert_eq!(stored, expected); + // Storage is funded by the contract, so the submitter pays only gas. + assert_only_gas_spent(&submitter, balance_before, &result).await; +} + +#[tokio::test] +async fn submit_participant_info__should_reject_verified_quote_when_tls_key_owned_by_other_account() +{ + // Given: an owner stored a Verified attestation for the fixture TLS key. The + // quote's report_data binds only the key pair, not the account id, and NEAR + // allows the same public key on two accounts, so nothing in the attestation + // itself distinguishes the second submitter; only the ownership guard does. + // That guard runs before verification, so the rejection is synchronous and + // never reaches the verifier. + let (worker, contract, owner) = setup_verified_fixture().await; + let contract = &contract; + submit_dstack(&owner, contract).await.into_result().unwrap(); + let stored_before = stored_fixture_attestation(contract) + .await + .expect("the owner's submission must store an attestation"); + let attacker = create_fixture_account(&worker, "fixture-node-b").await; + let balance_before = attacker.view_account().await.unwrap().balance; + + // When + let result = submit_dstack(&attacker, contract).await; + + // Then: match the whole result rather than a receipt, so the assertion holds + // whichever layer the guard rejects from. + assert!( + result.is_failure(), + "expected the attacker's submission to fail, got: {result:#?}" + ); + let rendered = format!("{result:?}"); + let expected = AttestationSubmissionError::TlsKeyOwnedByOtherAccount.to_string(); + assert!( + rendered.contains(&expected), + "expected a failure containing {expected:?}, got: {rendered}" + ); + let stored_after = stored_fixture_attestation(contract) + .await + .expect("the owner's attestation must survive the attack"); + assert_eq!( + stored_after, stored_before, + "the owner's entry must be unchanged" + ); + let tee_accounts = get_tee_accounts(contract).await.unwrap(); + assert!( + tee_accounts + .iter() + .any(|node| node.account_id.as_str() == owner.id().as_str() + && node.tls_public_key == p2p_tls_key().into()), + "the fixture TLS key must still belong to the owner, got: {tee_accounts:?}" + ); + assert_only_gas_spent(&attacker, balance_before, &result).await; +} + +#[tokio::test] +async fn submit_participant_info__should_reject_verified_quote_when_compose_hash_not_allowed() { + // Given: hashes voted in but the fixture's compose hash never patched, so the + // allowlist holds only the derived compose hash. + let (worker, contract, mpc_signer_accounts) = setup().await; + deploy_and_trust_pinned_verifier(&worker, &contract, &mpc_signer_accounts).await; + vote_fixture_image_and_launcher(&contract, &mpc_signer_accounts).await; + let submitter = create_fixture_account(&worker, "fixture-node-a").await; + let balance_before = prepay_grant_from_separate_payer(&worker, &contract, &submitter).await; // When let result = submit_dstack(&submitter, &contract).await; @@ -242,7 +588,46 @@ async fn submit_participant_info__should_fail_and_store_nothing_when_verifier_un &contract, &submitter, balance_before, - &TeeError::VerifierUnavailable, + &[ + "failed verification", + "launcher compose hash", + "is not in the allowed hashes list", + ], ) .await; + let remaining: u32 = contract + .view(method_names::AVAILABLE_ATTESTATION_GRANTS) + .args_json(serde_json::json!({ "account_id": submitter.id() })) + .await + .unwrap() + .json() + .unwrap(); + assert_eq!( + remaining, 1, + "a failed submission must not consume the grant" + ); +} + +#[tokio::test] +async fn verify_quote__should_ignore_pinned_timestamp_on_production_build() { + // Given + let worker = near_workspaces::sandbox().await.unwrap(); + let verifier = worker.dev_deploy(tee_verifier_contract()).await.unwrap(); + let args = include_bytes!("../../../tee-verifier/tests/fixtures/verify_quote_args.borsh"); + let unpinned = call_verify_quote(&verifier, args).await; + + // When: pin timestamps whose verdicts differ from block time in either direction, + // so honoring the pin would flip the outcome whichever side of the collateral + // window the wall clock is on. + let far_future = wall_clock_seconds() + 100 * 365 * 24 * 3600; + let mut pinned = Vec::new(); + for pin in [VALID_ATTESTATION_TIMESTAMP, far_future] { + pin_verifier_clock(&worker, verifier.id(), pin).await; + pinned.push(call_verify_quote(&verifier, args).await); + } + + // Then + for result in pinned { + assert_eq!(result, unpinned, "the production build must ignore the pin"); + } } diff --git a/crates/contract/tests/sandbox/utils/contract_build.rs b/crates/contract/tests/sandbox/utils/contract_build.rs index f99c3f154b..a76d694055 100644 --- a/crates/contract/tests/sandbox/utils/contract_build.rs +++ b/crates/contract/tests/sandbox/utils/contract_build.rs @@ -8,6 +8,7 @@ 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"; +const TEE_VERIFIER_SANDBOX_OUT_DIR: &str = "target/near/tee-verifier-sandbox"; static CONTRACT: OnceLock> = OnceLock::new(); static CONTRACT_WITH_BENCH_METHODS: OnceLock> = OnceLock::new(); @@ -15,6 +16,7 @@ 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(); +static TEE_VERIFIER_CONTRACT_WITH_SANDBOX_TEST_HOOKS: OnceLock> = OnceLock::new(); /// Returns the current contract WASM without benchmark utilities. /// Use this for most sandbox tests. @@ -60,3 +62,15 @@ pub fn parallel_contract() -> &'static [u8] { pub fn tee_verifier_contract() -> &'static [u8] { TEE_VERIFIER_CONTRACT.get_or_init(|| ContractBuilder::new(TEE_VERIFIER_MANIFEST).build()) } + +/// Returns the tee-verifier WASM with the pinnable verification clock enabled. +/// Use this for tests that need the time-expired fixture quote to reach a +/// Verified verdict; everything else should deploy [`tee_verifier_contract`]. +pub fn tee_verifier_contract_with_sandbox_test_hooks() -> &'static [u8] { + TEE_VERIFIER_CONTRACT_WITH_SANDBOX_TEST_HOOKS.get_or_init(|| { + ContractBuilder::new(TEE_VERIFIER_MANIFEST) + .out_dir(TEE_VERIFIER_SANDBOX_OUT_DIR) + .features(&["sandbox-test-hooks"]) + .build() + }) +} diff --git a/crates/contract/tests/sandbox/utils/mpc_contract.rs b/crates/contract/tests/sandbox/utils/mpc_contract.rs index 1a53110b20..f93c291191 100644 --- a/crates/contract/tests/sandbox/utils/mpc_contract.rs +++ b/crates/contract/tests/sandbox/utils/mpc_contract.rs @@ -3,13 +3,13 @@ use std::collections::BTreeSet; use crate::sandbox::utils::transactions::CallMpcContract; use super::transactions::all_receipts_successful; -use mpc_contract::tee::tee_state::NodeId; +use mpc_contract::tee::{measurements::ContractExpectedMeasurements, tee_state::NodeId}; use mpc_primitives::hash::{LauncherImageHash, NodeImageHash, TeeVerifierCodeHash}; use near_mpc_contract_interface::{ method_names, types::{ Attestation, Config, Ed25519PublicKey, GovernanceThreshold, Participants, - ProtocolContractState, + ProtocolContractState, VerifiedAttestation, }, }; use near_workspaces::{ @@ -24,6 +24,10 @@ pub fn total_gas_fee(result: &ExecutionFinalResult) -> NearToken { .fold(NearToken::from_yoctonear(0), NearToken::saturating_add) } +pub async fn get_config(contract: &Contract) -> anyhow::Result { + Ok(contract.view(method_names::CONFIG).await?.json()?) +} + pub async fn get_state(contract: &Contract) -> ProtocolContractState { contract .view(method_names::STATE) @@ -71,11 +75,7 @@ pub async fn prepay_attestation_grants( grants: u32, ) -> anyhow::Result { // The fee is read from `config()`, the way an operator reads it. - let config: Config = contract - .view(method_names::CONFIG) - .args_json(serde_json::json!({})) - .await? - .json()?; + let config = get_config(contract).await?; let total = NearToken::from_millinear( u128::from(config.attestation_storage_fee_millinear) * u128::from(grants), ); @@ -147,18 +147,14 @@ pub async fn tee_verifier_account_id(contract: &Contract) -> Option { pub async fn get_participant_attestation( contract: &Contract, tls_key: &Ed25519PublicKey, -) -> anyhow::Result> { - let result = contract - .as_account() - .call(contract.id(), method_names::GET_ATTESTATION) +) -> anyhow::Result> { + Ok(contract + .view(method_names::GET_ATTESTATION) .args_json(serde_json::json!({ "tls_public_key": tls_key })) - .max_gas() - .transact() - .await?; - - Ok(result.json()?) + .await? + .json()?) } pub async fn assert_running_return_participants( @@ -213,3 +209,17 @@ pub async fn vote_add_launcher_hash( all_receipts_successful(result)?; Ok(()) } + +pub async fn vote_add_os_measurement( + account: &Account, + contract: &Contract, + measurement: &ContractExpectedMeasurements, +) -> anyhow::Result<()> { + let result = account + .call(contract.id(), method_names::VOTE_ADD_OS_MEASUREMENT) + .args_json(serde_json::json!({"measurement": measurement})) + .transact() + .await?; + all_receipts_successful(result)?; + Ok(()) +} diff --git a/crates/e2e-tests/README.md b/crates/e2e-tests/README.md index d8cfb049ec..7d9fd21bba 100644 --- a/crates/e2e-tests/README.md +++ b/crates/e2e-tests/README.md @@ -125,9 +125,10 @@ impl NearBlockchain { `DeployedContract` wraps the contract's account ID plus its own `near-kit` client. It exposes `call`/`call_final` (from the contract account, used only for `init`), `handle_for` (a typed `MpcContractHandle` calling as a given -`NearKitCaller`), `call_from_with_deposit` (untyped escape hatch for -`prepay_attestation_storage`, which has no typed method yet), `view`, and -`state()` (parsed `ProtocolContractState`). +`NearKitCaller`), `call_from_with_deposit` (untyped escape hatch for methods +without a typed wrapper yet: `prepay_attestation_storage`, +`vote_tee_verifier_change`), `view`, and `state()` (parsed +`ProtocolContractState`). `NearKitCaller` binds a signer to a non-contract account (nodes voting, users submitting sign requests) and implements the `CallContract` transport trait, @@ -171,12 +172,16 @@ The entry point for tests. `MpcCluster::start(config)` does everything: 8. Call `init()` on the contract with the initial participants. 9. Call `submit_participant_info` for each initial participant (with a `{"Mock": "Valid"}` attestation — enough to satisfy the contract in tests). -10. Spawn the `mpc-node` binaries (start *before* adding domains so key +10. Deploy the tee-verifier WASM to `tee-verifier.sandbox` and vote it in from + every participant, for topology parity with production. Mock attestations + are verified without calling it, so the verifier stays idle; the + cross-contract flow is covered by the mpc-contract sandbox tests. +11. Spawn the `mpc-node` binaries (start *before* adding domains so key generation has running nodes to talk to). -11. Sleep briefly and assert no node exited early. -12. If `config.domains` is non-empty, vote `add_domains` from each participant +12. Sleep briefly and assert no node exited early. +13. If `config.domains` is non-empty, vote `add_domains` from each participant and wait for `Running` state. -13. Create user accounts for signing/CKD/verify requests. +14. Create user accounts for signing/CKD/verify requests. The returned cluster exposes: @@ -207,6 +212,7 @@ pub struct MpcClusterConfig { pub domains: Vec, pub binary_paths: Vec, // one or num_nodes pub contract_wasm: Vec, // pre-compiled by the test + pub tee_verifier_wasm: Vec, // loaded via MPC_TEE_VERIFIER_WASM pub port_seed: u16, pub triples_to_buffer: usize, pub presignatures_to_buffer: usize, @@ -330,12 +336,13 @@ so any nextest filter or flag works (substring filters, `-E` expressions, runs with the `ci-e2e` profile. Do not put flags after a `--` separator: it is forwarded verbatim, and nextest only accepts filters, not flags, after `--`. -The task runner builds three things before tests run: the mpc-node binary -with the `network-hardship-simulation` feature, the MPC contract WASM, and -the test parallel contract WASM. Paths are passed to tests via the -`MPC_CONTRACT_WASM` and `MPC_PARALLEL_CONTRACT_WASM` environment variables -read by `must_load_contract_wasm` / `must_load_parallel_contract_wasm` in -`tests/common.rs`; if the env var is unset and no pre-built WASM is found, +The task runner builds five things before tests run: the mpc-node binary with +the `network-hardship-simulation` feature, the MPC contract WASM, the +tee-verifier WASM, the test parallel contract WASM, and the backup CLI. WASM +paths are passed to tests via the `MPC_CONTRACT_WASM`, +`MPC_TEE_VERIFIER_WASM` and `MPC_PARALLEL_CONTRACT_WASM` environment +variables, read by the `must_load_*` helpers in `tests/common.rs` and +`src/cluster.rs`; if the env var is unset and no pre-built WASM is found, `test-utils::contract_build::ContractBuilder` builds it on the fly (useful for local iteration). diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 058d8f0de9..28e9839aa4 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -54,6 +54,8 @@ pub fn cluster_poll_retry() -> ConstantBuilder { ) } +const VOTE_TEE_VERIFIER_GAS: near_kit::Gas = near_kit::Gas::from_tgas(100); + // The contract's default `key_event_timeout_blocks = 30` is ~18 s on // mainnet (~600 ms blocks). The e2e sandbox runs ~8 blocks/s, so the // same 30 collapses to ~3.7 s — too tight for the resharing @@ -71,6 +73,7 @@ const KEY_SEED_P2P: u64 = 100; const KEY_SEED_OPERATOR: u64 = 200; const KEY_SEED_MIGRATION_P2P: u64 = 300; const KEY_SEED_MIGRATION_NEAR_SIGNER: u64 = 400; +const KEY_SEED_TEE_VERIFIER: u64 = 500; /// Configuration for creating a new [`MpcCluster`]. pub struct MpcClusterConfig { @@ -84,6 +87,12 @@ pub struct MpcClusterConfig { pub binary_paths: Vec, /// Compiled contract WASM bytes (pre-compiled by the test). pub contract_wasm: Vec, + /// Compiled tee-verifier WASM bytes, deployed and voted in during cluster + /// startup for topology parity with production. Nodes in e2e clusters + /// submit mock attestations, which the MPC contract verifies without + /// calling the verifier; the cross-contract flow itself is covered by the + /// mpc-contract sandbox tests. + pub tee_verifier_wasm: Vec, /// Port seed for the port allocator (must be unique across parallel tests). pub port_seed: u16, /// Triple buffer size per node. @@ -196,6 +205,7 @@ impl MpcClusterConfig { ], binary_paths: vec![default_mpc_binary_path()], contract_wasm, + tee_verifier_wasm: must_load_tee_verifier_wasm(), port_seed, triples_to_buffer: DEFAULT_TRIPLES_TO_BUFFER, presignatures_to_buffer: DEFAULT_PRESIGNATURES_TO_BUFFER, @@ -226,6 +236,15 @@ impl MpcClusterConfig { self.num_nodes, ); } + // Startup indexes the key vectors by participant index, so an out-of-range + // entry here would otherwise surface as a panic mid-startup. + for (i, &participant_idx) in self.initial_participant_indices.iter().enumerate() { + anyhow::ensure!( + participant_idx < self.num_nodes, + "initial_participant_indices[{i}]: index {participant_idx} must be < num_nodes ({})", + self.num_nodes, + ); + } Ok(()) } } @@ -234,6 +253,37 @@ fn default_mpc_binary_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/release/mpc-node") } +/// Plumbing helper: failures here are setup bugs, not test failures, so we panic. +pub fn must_load_tee_verifier_wasm() -> Vec { + let default_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/near/tee_verifier/tee_verifier.wasm"); + let wasm_path = match std::env::var("MPC_TEE_VERIFIER_WASM") { + Ok(path) => PathBuf::from(path), + Err(_) if default_path.exists() => default_path, + Err(_) => { + tracing::info!( + "MPC_TEE_VERIFIER_WASM not set and pre-built WASM not found; building \ + tee-verifier. Build it up front with `cargo make build-tee-verifier-optimized` \ + to skip this." + ); + // Same out dir the probe above checks, so this build is found and reused + // by later test processes instead of rebuilding each time. + return test_utils::contract_build::ContractBuilder::new( + "crates/tee-verifier/Cargo.toml", + ) + .out_dir("target/near/tee_verifier") + .build(); + } + }; + std::fs::read(&wasm_path).unwrap_or_else(|e| { + panic!( + "failed to read tee-verifier WASM at {}: {e}. Build it with \ + `cargo make build-tee-verifier-optimized` (skipped when E2E_SKIP_BUILD is set)", + wasm_path.display() + ) + }) +} + /// A running MPC test cluster with a deployed contract and N mpc-node processes. /// /// Orchestrates the full test environment: sandbox -> contract -> @@ -322,6 +372,15 @@ impl MpcCluster { ) .await?; + deploy_and_trust_tee_verifier( + &blockchain, + &contract, + &config.tee_verifier_wasm, + &operator_keys, + &participant_indices, + ) + .await?; + // Start MPC nodes BEFORE adding domains: key generation requires running nodes. let mut nodes = start_mpc_nodes( &config, @@ -1356,6 +1415,66 @@ async fn init_contract( .context("contract did not reach Running state after init") } +/// Deploys the tee-verifier and votes it in from every participant, mirroring +/// the production topology (cf. `scripts/launch-localnet.sh`). +async fn deploy_and_trust_tee_verifier( + blockchain: &NearBlockchain, + contract: &DeployedContract, + verifier_wasm: &[u8], + operator_keys: &[SigningKey], + participant_indices: &[usize], +) -> anyhow::Result<()> { + let verifier_account = format!("tee-verifier.{SANDBOX_ROOT_ACCOUNT}"); + let verifier_key = generate_deterministic_key(KEY_SEED_TEE_VERIFIER); + tracing::info!(account = %verifier_account, "deploying tee-verifier contract"); + // The verifier is stateless, so there is no initializer to call on deploy. + blockchain + .create_account_and_deploy(&verifier_account, 100, &verifier_key, verifier_wasm) + .await?; + + // expected_code_hash commits every voter to the same audited WASM; the + // contract only compares voters' hashes against each other, not against + // the deployed bytes. + let expected_code_hash = hex::encode(near_kit::CryptoHash::hash(verifier_wasm).as_bytes()); + let args = json!({ + "candidate_account_id": verifier_account, + "expected_code_hash": expected_code_hash, + }); + for &i in participant_indices { + let account = node_account(i); + let client = blockchain.client_for(&account, &operator_keys[i])?; + let outcome = contract + .call_from_with_deposit( + &client, + method_names::VOTE_TEE_VERIFIER_CHANGE, + args.clone(), + VOTE_TEE_VERIFIER_GAS, + near_kit::NearToken::from_yoctonear(0), + ) + .await + .with_context(|| format!("node {i} failed to vote for the tee-verifier"))?; + anyhow::ensure!( + outcome.is_success(), + "node {i}'s tee-verifier vote failed: {:?}", + outcome.failure_message() + ); + } + + // The votes are not awaited to finality, so views can lag them; poll like + // the post-init state waits do. + (|| async { + let resolved: Option = contract.view(method_names::TEE_VERIFIER_ACCOUNT_ID).await?; + anyhow::ensure!( + resolved.as_deref() == Some(verifier_account.as_str()), + "tee-verifier vote has not crossed threshold, resolved verifier: {resolved:?}" + ); + Ok(()) + }) + .retry(cluster_poll_retry()) + .await + .context("tee-verifier not resolved as the trusted verifier") +} + async fn add_initial_domains( blockchain: &NearBlockchain, contract: &DeployedContract, diff --git a/crates/tee-verifier-interface/src/lib.rs b/crates/tee-verifier-interface/src/lib.rs index 5dd2c3467a..7ba25d16f7 100644 --- a/crates/tee-verifier-interface/src/lib.rs +++ b/crates/tee-verifier-interface/src/lib.rs @@ -190,6 +190,16 @@ pub enum VerificationResult { Rejected(VerifierError), } +/// Storage key under which a sandbox test can pin the timestamp `verify_quote` +/// verifies against, as u64 little-endian seconds since the Unix epoch. +/// +/// Honored only by verifier builds with the `sandbox-test-hooks` feature and +/// the `mpc_sandbox_wasm` cfg marker set by the test harness; production +/// builds never read it. Tests write it from outside the contract +/// (state patching), which is why the key must be shared between the verifier +/// and its callers' test suites rather than staying private. +pub const SANDBOX_TEST_PINNED_NOW_STORAGE_KEY: &[u8] = b"sandbox_test_pinned_now_seconds"; + #[cfg(test)] #[expect(non_snake_case)] mod tests { diff --git a/crates/tee-verifier/Cargo.toml b/crates/tee-verifier/Cargo.toml index ae5bb5f6a2..00018b9836 100644 --- a/crates/tee-verifier/Cargo.toml +++ b/crates/tee-verifier/Cargo.toml @@ -34,6 +34,10 @@ abi = ["borsh/unstable__schema", "tee-verifier-interface/borsh-schema"] # Enables `near_sdk::testing_env!` for tests; the workspace `near-sdk` # dependency no longer turns on `unit-testing` by default. test-utils = ["near-sdk/unit-testing"] +# Lets sandbox tests pin the timestamp quotes are verified against, so the +# fixture collateral verifies regardless of the wall clock. Takes effect only +# together with `--cfg mpc_sandbox_wasm`, which only the test harness sets. +sandbox-test-hooks = [] [dependencies] borsh = { workspace = true } diff --git a/crates/tee-verifier/src/lib.rs b/crates/tee-verifier/src/lib.rs index 75c1af6bc3..0d24da153a 100644 --- a/crates/tee-verifier/src/lib.rs +++ b/crates/tee-verifier/src/lib.rs @@ -9,10 +9,21 @@ //! See `docs/design/attestation-verifier-contract.md` for the design. use near_sdk::{env, near}; +#[cfg(all(feature = "sandbox-test-hooks", mpc_sandbox_wasm))] +use tee_verifier_interface::SANDBOX_TEST_PINNED_NOW_STORAGE_KEY; use tee_verifier_interface::{Collateral, QuoteBytes, VerificationResult, VerifierError}; use tee_verifier_conversions::{IntoDcapType as _, IntoInterfaceType as _}; +// Only the test-built wasm (marked with `--cfg mpc_sandbox_wasm` by +// `test_utils::contract_build::ContractBuilder`) may carry the pinned clock. +#[cfg(all( + target_arch = "wasm32", + feature = "sandbox-test-hooks", + not(mpc_sandbox_wasm) +))] +compile_error!("sandbox-test-hooks must not be enabled in a shipped wasm build"); + // `dcap-qvl`'s `contract` feature pulls in `getrandom` but doesn't enable // any backend. On `wasm32-unknown-unknown` we register a custom impl that // returns `UNSUPPORTED`. Quote verification should not draw any randomness; @@ -33,8 +44,9 @@ impl TeeVerifier { /// Verify a TDX quote against Intel collateral. /// /// Calls [`dcap_qvl::verify::verify`] with the current block timestamp - /// and returns `VerificationResult::Verified(report)` on success. The - /// caller is responsible for any post-DCAP policy (RTMR3 replay, + /// (pinnable by sandbox tests in builds with the `sandbox-test-hooks` + /// feature) and returns [`VerificationResult::Verified`] with the report on success. + /// The caller is responsible for any post-DCAP policy (RTMR3 replay, /// report-data binding, measurement allowlist matching, etc.). /// /// A rejected quote returns [`VerificationResult::Rejected`] as the @@ -51,7 +63,7 @@ impl TeeVerifier { #[serializer(borsh)] quote: QuoteBytes, #[serializer(borsh)] collateral: Collateral, ) -> VerificationResult { - let now_seconds = env::block_timestamp_ms() / 1000; + let now_seconds = now_seconds(); let quote_bytes: Vec = quote.into_dcap_type(); let collateral = collateral.into_dcap_type(); match dcap_qvl::verify::verify("e_bytes, &collateral, now_seconds) { @@ -62,3 +74,19 @@ impl TeeVerifier { } } } + +/// The timestamp quotes are verified against: block time, unless a sandbox test +/// pinned one under [`tee_verifier_interface::SANDBOX_TEST_PINNED_NOW_STORAGE_KEY`]. +/// The pin exists because sandbox chain time is wall-clock and forward-only: once it +/// passes the fixed validity window of a checked-in collateral fixture it never +/// returns, so unpinned runs would start failing on that date. +fn now_seconds() -> u64 { + #[cfg(all(feature = "sandbox-test-hooks", mpc_sandbox_wasm))] + if let Some(bytes) = env::storage_read(SANDBOX_TEST_PINNED_NOW_STORAGE_KEY) { + let bytes: [u8; 8] = bytes + .try_into() + .expect("pinned timestamp must be exactly 8 little-endian bytes"); + return u64::from_le_bytes(bytes); + } + env::block_timestamp_ms() / 1000 +} diff --git a/crates/test-utils/src/contract_build.rs b/crates/test-utils/src/contract_build.rs index abd1c0f6db..83ca9afbcb 100644 --- a/crates/test-utils/src/contract_build.rs +++ b/crates/test-utils/src/contract_build.rs @@ -75,12 +75,17 @@ impl ContractBuilder { .expect("path must be valid UTF-8") }; + // Marks the artifact as test-built; sandbox-only code compiles in only under this cfg. + let inherited = std::env::var("RUSTFLAGS").unwrap_or_default(); + let rustflags = format!("{inherited} --cfg mpc_sandbox_wasm"); + let opts = cargo_near_build::BuildOpts { manifest_path: Some(to_utf8(abs_manifest)), out_dir: Some(to_utf8(workspace_root().join(out_dir))), profile: Some("release-contract".to_string()), no_abi: true, no_embed_abi: true, + env: vec![("RUSTFLAGS".to_string(), rustflags)], features: if self.features.is_empty() { None } else { diff --git a/docs/deploy-tee-verifier.md b/docs/deploy-tee-verifier.md index 29384a93cd..41b2f356fd 100644 --- a/docs/deploy-tee-verifier.md +++ b/docs/deploy-tee-verifier.md @@ -66,7 +66,7 @@ near account create-account sponsor-by-faucet-service "$VERIFIER_ACCOUNT" autoge On `mainnet` there is no faucet; create and fund the account from an existing one. The balance only needs to cover storage staking for the deployed WASM (about 1 NEAR per -100 KB); 5 NEAR comfortably covers the ~360 KB verifier: +100 KB); 5 NEAR covers the ~430 KB verifier: ```shell near account create-account fund-myself "$VERIFIER_ACCOUNT" '5 NEAR' autogenerate-new-keypair save-to-keychain sign-as network-config "$NETWORK" sign-with-keychain send @@ -106,7 +106,8 @@ Optionally confirm the contract executes by calling `verify_quote` read-only wit committed fixture. Either outcome proves the DCAP path runs: a verified report while the fixture's collateral is inside its validity window, or `TCBInfo expired` once the live clock passes it. Tests pin the verification clock instead of relying on that -window (`crates/tee-verifier/tests/verify_quote.rs`): +window (`crates/tee-verifier/tests/verify_quote.rs` and, cross-contract, the sandbox +tests in `crates/contract/tests/sandbox/tee_verifier.rs`): ```shell near contract call-function as-read-only "$VERIFIER_ACCOUNT" verify_quote file-args crates/tee-verifier/tests/fixtures/verify_quote_args.borsh network-config "$NETWORK" now diff --git a/docs/design/attestation-verifier-contract.md b/docs/design/attestation-verifier-contract.md index d64cee47c0..18e5c13317 100644 --- a/docs/design/attestation-verifier-contract.md +++ b/docs/design/attestation-verifier-contract.md @@ -611,11 +611,15 @@ The yield-resume split adds four resolution branches the synchronous version nev The verifier-rotation design changes the test surface in three ways. First, the expiration window itself: an entry whose `expiry_timestamp_seconds` is in the past must be rejected by `re_verify` even when every post-DCAP allowlist invariant still holds, and an entry within the (shortened) window must still pass — this is the existing expiry check, now exercised against the lowered `DEFAULT_EXPIRATION_DURATION_SECONDS`. Second, rotation routing: after `vote_tee_verifier_change` crosses threshold, the next `submit_participant_info` must call `verify_quote` on the new `tee_verifier_account_id`, and existing stored entries must remain present (no purge) until they expire. Third, the in-flight case: a verification scheduled against the old verifier that resolves after the vote crosses threshold must still be stored as a normal entry — it is not treated specially and ages out via the same expiration window as any other entry. -To make that practical, we introduce a stub `tee-verifier` crate: same `tee-verifier-interface` DTOs as the real verifier, but `verify_quote` returns whatever `VerificationResult` (`Verified` or `Rejected`) the test asks for — and a stub that panics, or an undeployed account, covers the no-verdict path. Sandbox tests deploy the stub like any other verifier candidate — lock its account, then call `vote_tee_verifier_change` from the test setup to point `mpc-contract` at the stub. This runs the same code path as production; nothing in `mpc-contract` knows or cares whether it's talking to the real verifier or the stub. +Status: the stub verifier this section originally proposed was dropped during implementation, because a second contract mirroring the real one duplicated it for little gain. What shipped: -E2E tests in `crates/e2e-tests` deploy either the real `tee-verifier` (when the test wants real `dcap-qvl` against a fixture quote) or the stub (for everything else). The change is one extra `deploy` call in the setup helper. +Sandbox tests in `crates/contract/tests/sandbox/tee_verifier.rs` deploy the real `tee-verifier` WASM and drive each verdict through `vote_tee_verifier_change` + `submit_participant_info`: `Rejected` with a malformed quote, no-verdict with an undeployed verifier account, and `Verified` with the fixture quote. `Verified` needs the verifier built with `sandbox-test-hooks`, which lets the test pin the timestamp `verify_quote` verifies against (the fixture collateral is valid only inside a fixed window, while sandbox time is wall-clock and forward-only); the feature takes effect only together with the `mpc_sandbox_wasm` cfg the test harness sets on the wasm it builds, so no released artifact can carry the pin. The fixture's launcher compose hash is patched straight into the contract's state, since no vote can derive it. -`Attestation::Mock` stays in this iteration. The stub eventually supersedes it — both let tests bypass real `dcap-qvl` — but removing `Mock` is a separate cleanup, not in scope here. +Tests that assert the *store* additionally sign as the fixture account, because the quote's report_data binds that account key; the committed fixture secret key (`crates/test-utils/assets/near_account_secret_key`) makes that signature possible. + +E2E tests in `crates/e2e-tests` deploy the real `tee-verifier` and vote it in during cluster startup for topology parity; nodes there submit mock attestations, which the MPC contract verifies without calling the verifier, so the cross-contract flow is covered at the sandbox layer. + +`Attestation::Mock` stays in this iteration; removing it is a separate cleanup, not in scope here. [nep-509]: https://github.com/near/NEPs/blob/master/neps/nep-0509.md [re-verify]: https://github.com/near/mpc/blob/5e47bfe93b398cb2343681fa2c0f2691d02c7285/crates/mpc-attestation/src/attestation.rs#L93 diff --git a/docs/localnet/localnet.md b/docs/localnet/localnet.md index 6a51410eef..2620f85379 100644 --- a/docs/localnet/localnet.md +++ b/docs/localnet/localnet.md @@ -347,7 +347,8 @@ Either outcome proves the DCAP path runs: a verified report while the fixture's collateral is still inside its validity window (it ends at the `nextUpdate` in `crates/test-utils/assets/collateral.json`), and `TCBInfo expired` once the live block clock passes it. Tests pin the verification clock instead -(`crates/tee-verifier/tests/verify_quote.rs`). Regenerate the +(`crates/tee-verifier/tests/verify_quote.rs`, and cross-contract +`crates/contract/tests/sandbox/tee_verifier.rs`). Regenerate the fixture (after changing the quote/collateral fixtures) with: ```shell