Skip to content

Commit b4bea09

Browse files
authored
fix(contract): make mock attestations cleanable via expiry (#3785)
1 parent a3cb85b commit b4bea09

11 files changed

Lines changed: 451 additions & 36 deletions

File tree

crates/contract/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ The MPC nodes will eventually run inside a Trusted Execution Environments (TEE).
359359

360360
Participants that run their node inside a TEE will have to submit the following TEE related data to the contract:
361361

362-
```rust
362+
```rust,ignore
363363
pub struct DstackAttestation {
364364
/// TEE Remote Attestation Quote that proves the participant's identity.
365365
pub quote: Quote,

crates/contract/src/tee/tee_state.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ impl TeeState {
146146
tee_state
147147
}
148148

149-
fn current_time_seconds() -> u64 {
149+
pub(crate) fn current_time_seconds() -> u64 {
150150
env::block_timestamp_ms() / 1_000
151151
}
152152

@@ -717,6 +717,81 @@ mod tests {
717717
);
718718
}
719719

720+
#[test]
721+
fn clean_invalid_attestations__should_remove_accepted_mock_valid_after_expiry() {
722+
// Given: a `MockAttestation::Valid` accepted via the normal submission path.
723+
// It must be stamped with an expiry so it can eventually be cleaned up.
724+
testing_env!(VMContextBuilder::new().block_timestamp(0).build());
725+
726+
let mut tee_state = TeeState::default();
727+
let node_id = NodeId {
728+
account_id: "alice.near".parse().unwrap(),
729+
tls_public_key: bogus_ed25519_public_key(),
730+
account_public_key: bogus_ed25519_public_key(),
731+
};
732+
tee_state
733+
.verify_and_store_mock(
734+
node_id.clone(),
735+
MockAttestation::Valid,
736+
Duration::from_secs(0),
737+
)
738+
.unwrap();
739+
740+
// Before expiry: cleanup keeps the entry.
741+
assert_eq!(
742+
tee_state.clean_invalid_attestations(Duration::from_secs(0), 100),
743+
0
744+
);
745+
746+
// When: the clock advances past the stamped expiry window and cleanup runs.
747+
set_block_timestamp((attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + 1) * 1_000_000_000);
748+
let removed = tee_state.clean_invalid_attestations(Duration::from_secs(0), 100);
749+
750+
// Then: the previously uncleanable mock entry is removed.
751+
assert_eq!(removed, 1);
752+
assert!(
753+
!tee_state
754+
.stored_attestations
755+
.contains_key(&node_id.tls_public_key)
756+
);
757+
}
758+
759+
#[test]
760+
fn verify_and_store_mock__should_cap_a_submitter_supplied_expiry_beyond_the_default() {
761+
// Given: a mock submitted with an expiry far beyond the contract's default
762+
// window. A submitter must not be able to extend its lifetime past the cap.
763+
testing_env!(VMContextBuilder::new().block_timestamp(0).build());
764+
765+
let mut tee_state = TeeState::default();
766+
let node_id = NodeId {
767+
account_id: "alice.near".parse().unwrap(),
768+
tls_public_key: bogus_ed25519_public_key(),
769+
account_public_key: bogus_ed25519_public_key(),
770+
};
771+
let oversized = MockAttestation::WithConstraints {
772+
mpc_docker_image_hash: None,
773+
launcher_docker_compose_hash: None,
774+
expiry_timestamp_seconds: Some(attestation::DEFAULT_EXPIRATION_DURATION_SECONDS * 10),
775+
expected_measurements: None,
776+
};
777+
tee_state
778+
.verify_and_store_mock(node_id.clone(), oversized, Duration::from_secs(0))
779+
.unwrap();
780+
781+
// When: the clock advances just past the contract's default window — well
782+
// before the submitter's requested expiry.
783+
set_block_timestamp((attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + 1) * 1_000_000_000);
784+
let removed = tee_state.clean_invalid_attestations(Duration::from_secs(0), 100);
785+
786+
// Then: the entry is cleaned — the submitted expiry was capped at the default.
787+
assert_eq!(removed, 1);
788+
assert!(
789+
!tee_state
790+
.stored_attestations
791+
.contains_key(&node_id.tls_public_key)
792+
);
793+
}
794+
720795
#[test]
721796
fn clean_invalid_attestations__should_honor_max_scan() {
722797
// Given: ten expired attestations stored.

crates/contract/src/v3_13_0_state.rs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
//! A better approach: only copy the structures that have changed and import the rest from the existing codebase.
99
1010
use borsh::{BorshDeserialize, BorshSerialize};
11-
use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest};
11+
use mpc_attestation::attestation::{self, VerifiedAttestation};
12+
use near_mpc_contract_interface::types::{
13+
Ed25519PublicKey, Metrics, VerifyForeignTransactionRequest,
14+
};
1215
use near_sdk::{
1316
AccountId, env,
1417
store::{Lazy, LookupMap},
@@ -103,12 +106,59 @@ pub struct MpcContract {
103106
tee_verifier_votes: TeeVerifierVotes,
104107
}
105108

109+
/// Stamps an expiry on every stored mock attestation that lacks or exceeds one —
110+
/// both user-submitted mocks and the genesis sentinels written by
111+
/// [`TeeState::with_mocked_participant_attestations`]. Legacy
112+
/// [`mpc_attestation::attestation::MockAttestation::Valid`] entries pass
113+
/// re-verification forever and can therefore never be evicted by
114+
/// [`TeeState::clean_invalid_attestations`];
115+
/// [`mpc_attestation::attestation::MockAttestation::with_expiry_capped_at`] rewrites them as
116+
/// expiring mocks so the normal cleanup flow can remove stale entries once the
117+
/// window elapses. An entry whose expiry is longer than (or missing) the default
118+
/// window is capped at it; a shorter existing expiry is left as-is.
119+
///
120+
// TODO(#3978): transitional one-time upgrade step — removed together with this
121+
// module when the pre-expiry migration is retired.
122+
fn stamp_expiry_on_legacy_mocks(tee_state: &mut TeeState, current_timestamp_seconds: u64) {
123+
let expiry_timestamp_seconds =
124+
current_timestamp_seconds + attestation::DEFAULT_EXPIRATION_DURATION_SECONDS;
125+
126+
// Collect keys before mutating to avoid iterator invalidation.
127+
let mock_tls_keys: Vec<Ed25519PublicKey> = tee_state
128+
.stored_attestations
129+
.iter()
130+
.filter(|(_, node_attestation)| {
131+
matches!(
132+
node_attestation.verified_attestation,
133+
VerifiedAttestation::Mock(_)
134+
)
135+
})
136+
.map(|(tls_pk, _)| tls_pk.clone())
137+
.collect();
138+
139+
for tls_pk in mock_tls_keys {
140+
let Some(node_attestation) = tee_state.stored_attestations.get_mut(&tls_pk) else {
141+
continue;
142+
};
143+
if let VerifiedAttestation::Mock(mock) = &node_attestation.verified_attestation {
144+
let stamped = mock.clone().with_expiry_capped_at(expiry_timestamp_seconds);
145+
node_attestation.verified_attestation = VerifiedAttestation::Mock(stamped);
146+
}
147+
}
148+
}
149+
106150
impl From<MpcContract> for crate::MpcContract {
107151
fn from(old: MpcContract) -> Self {
108152
if !matches!(old.protocol_state, ProtocolContractState::Running(_)) {
109153
env::panic_str("Contract must be in running state when migrating.");
110154
}
111155

156+
// Legacy `MockAttestation::Valid` entries never expire and can never be
157+
// cleaned up. Stamp an expiry on them so the standard cleanup flow can
158+
// evict stale mock entries after the upgrade.
159+
let mut tee_state = old.tee_state;
160+
stamp_expiry_on_legacy_mocks(&mut tee_state, TeeState::current_time_seconds());
161+
112162
crate::MpcContract {
113163
protocol_state: old.protocol_state,
114164
pending_signature_requests: old.pending_signature_requests,
@@ -117,7 +167,7 @@ impl From<MpcContract> for crate::MpcContract {
117167
proposed_updates: old.proposed_updates,
118168
node_foreign_chain_support: old.node_foreign_chain_support,
119169
config: old.config.into(),
120-
tee_state: old.tee_state,
170+
tee_state,
121171
accept_requests: old.accept_requests,
122172
node_migrations: old.node_migrations,
123173
metrics: old.metrics,
@@ -127,3 +177,59 @@ impl From<MpcContract> for crate::MpcContract {
127177
}
128178
}
129179
}
180+
181+
#[cfg(test)]
182+
#[expect(non_snake_case)]
183+
mod tests {
184+
use super::{TeeState, VerifiedAttestation, attestation, stamp_expiry_on_legacy_mocks};
185+
use crate::primitives::test_utils::bogus_ed25519_public_key;
186+
use crate::tee::tee_state::{NodeAttestation, NodeId};
187+
use crate::tee::test_utils::set_block_timestamp;
188+
use mpc_attestation::attestation::MockAttestation;
189+
use near_sdk::test_utils::VMContextBuilder;
190+
use near_sdk::testing_env;
191+
use std::time::Duration;
192+
193+
#[test]
194+
fn stamp_expiry_on_legacy_mocks__should_make_valid_mock_cleanable() {
195+
// Given: a legacy `MockAttestation::Valid` entry stored with no expiry, as
196+
// written by older contract versions. Such entries pass re-verification
197+
// forever and cannot be cleaned up.
198+
testing_env!(VMContextBuilder::new().block_timestamp(0).build());
199+
200+
let mut tee_state = TeeState::default();
201+
let node_id = NodeId {
202+
account_id: "legacy.near".parse().unwrap(),
203+
tls_public_key: bogus_ed25519_public_key(),
204+
account_public_key: bogus_ed25519_public_key(),
205+
};
206+
tee_state.stored_attestations.insert(
207+
node_id.tls_public_key.clone(),
208+
NodeAttestation {
209+
node_id: node_id.clone(),
210+
verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid),
211+
},
212+
);
213+
214+
// Sanity: past the default window but without migration, the un-stamped
215+
// entry survives cleanup indefinitely.
216+
set_block_timestamp((attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + 1) * 1_000_000_000);
217+
assert_eq!(
218+
tee_state.clean_invalid_attestations(Duration::from_secs(0), 100),
219+
0
220+
);
221+
222+
// When: the migration stamps an expiry as of block time 0 (window ends at
223+
// DEFAULT), which the clock (already at DEFAULT + 1) is past.
224+
stamp_expiry_on_legacy_mocks(&mut tee_state, 0);
225+
let removed = tee_state.clean_invalid_attestations(Duration::from_secs(0), 100);
226+
227+
// Then: the stale legacy mock entry is removed.
228+
assert_eq!(removed, 1);
229+
assert!(
230+
!tee_state
231+
.stored_attestations
232+
.contains_key(&node_id.tls_public_key)
233+
);
234+
}
235+
}

crates/contract/tests/sandbox/tee.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ async fn get_attestation_returns_none_when_tls_key_is_not_associated_with_an_att
588588
#[tokio::test]
589589
async fn get_attestation_returns_some_when_tls_key_associated_with_an_attestation() {
590590
let SandboxTestSetup {
591+
worker,
591592
contract,
592593
mpc_signer_accounts,
593594
..
@@ -607,17 +608,21 @@ async fn get_attestation_returns_some_when_tls_key_associated_with_an_attestatio
607608
"Sanity check failed. Participant tls keys can not be equal for this test."
608609
);
609610

611+
// Expiries within the default window, so the contract's expiry cap keeps them
612+
// as-is (a stored mock's expiry is `min(submitted, now + default window)`) and
613+
// the two attestations stay distinct.
614+
let now_seconds = worker.view_block().await.unwrap().timestamp() / 1_000_000_000;
610615
let participant_1_attestation = Attestation::Mock(MockAttestation::WithConstraints {
611616
mpc_docker_image_hash: None,
612617
launcher_docker_compose_hash: None,
613-
expiry_timestamp_seconds: Some(u64::MAX),
618+
expiry_timestamp_seconds: Some(now_seconds + 1_000),
614619
expected_measurements: None,
615620
});
616621

617622
let participant_2_attestation = Attestation::Mock(MockAttestation::WithConstraints {
618623
mpc_docker_image_hash: None,
619624
launcher_docker_compose_hash: None,
620-
expiry_timestamp_seconds: Some(u64::MAX - 1),
625+
expiry_timestamp_seconds: Some(now_seconds + 2_000),
621626
expected_measurements: None,
622627
});
623628

@@ -659,6 +664,7 @@ async fn get_attestation_returns_some_when_tls_key_associated_with_an_attestatio
659664
#[tokio::test]
660665
async fn get_attestation_overwrites_when_same_tls_key_is_reused() {
661666
let SandboxTestSetup {
667+
worker,
662668
contract,
663669
mpc_signer_accounts,
664670
..
@@ -670,17 +676,21 @@ async fn get_attestation_overwrites_when_same_tls_key_is_reused() {
670676
let participant_account = &mpc_signer_accounts[0];
671677
let tls_key = bogus_ed25519_public_key();
672678

679+
// Expiries within the default window, so the contract's expiry cap keeps them
680+
// as-is (a stored mock's expiry is `min(submitted, now + default window)`) and
681+
// the two attestations stay distinct.
682+
let now_seconds = worker.view_block().await.unwrap().timestamp() / 1_000_000_000;
673683
let first_attestation = Attestation::Mock(MockAttestation::WithConstraints {
674684
mpc_docker_image_hash: None,
675685
launcher_docker_compose_hash: None,
676-
expiry_timestamp_seconds: Some(u64::MAX),
686+
expiry_timestamp_seconds: Some(now_seconds + 1_000),
677687
expected_measurements: None,
678688
});
679689

680690
let second_attestation = Attestation::Mock(MockAttestation::WithConstraints {
681691
mpc_docker_image_hash: None,
682692
launcher_docker_compose_hash: None,
683-
expiry_timestamp_seconds: Some(u64::MAX - 1),
693+
expiry_timestamp_seconds: Some(now_seconds + 2_000),
684694
expected_measurements: None,
685695
});
686696

0 commit comments

Comments
 (0)