Skip to main content

a3s_box_runtime/tee/
reattest.rs

1//! Periodic re-attestation for long-running TEE workloads.
2//!
3//! Verifies the TEE's attestation report at configurable intervals to detect
4//! runtime compromise. If re-attestation fails, the configured action is taken
5//! (log warning, emit event, or stop the VM).
6
7use std::time::{Duration, Instant};
8
9use a3s_box_core::error::{BoxError, Result};
10use serde::{Deserialize, Serialize};
11
12/// Configuration for periodic re-attestation.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ReattestConfig {
15    /// Whether periodic re-attestation is enabled
16    pub enabled: bool,
17    /// Interval between re-attestation checks in seconds (default: 300 = 5 min)
18    #[serde(default = "default_interval")]
19    pub interval_secs: u64,
20    /// Maximum consecutive failures before taking action (default: 3)
21    #[serde(default = "default_max_failures")]
22    pub max_failures: u32,
23    /// Action to take on persistent failure
24    #[serde(default)]
25    pub failure_action: FailureAction,
26    /// Grace period after boot before first check, in seconds (default: 60)
27    #[serde(default = "default_grace_period")]
28    pub grace_period_secs: u64,
29}
30
31fn default_interval() -> u64 {
32    300
33}
34
35fn default_max_failures() -> u32 {
36    3
37}
38
39fn default_grace_period() -> u64 {
40    60
41}
42
43impl Default for ReattestConfig {
44    fn default() -> Self {
45        Self {
46            enabled: false,
47            interval_secs: 300,
48            max_failures: 3,
49            failure_action: FailureAction::default(),
50            grace_period_secs: 60,
51        }
52    }
53}
54
55/// Action to take when re-attestation persistently fails.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub enum FailureAction {
58    /// Log a warning but continue running (default)
59    #[default]
60    Warn,
61    /// Emit an event for external monitoring systems
62    Event,
63    /// Stop the VM immediately
64    Stop,
65}
66
67/// Tracks the state of periodic re-attestation for a single VM.
68pub struct ReattestState {
69    config: ReattestConfig,
70    /// When the VM was started (for grace period)
71    boot_time: Instant,
72    /// When the last successful attestation occurred
73    last_success: Option<Instant>,
74    /// When the last check was attempted
75    last_check: Option<Instant>,
76    /// Consecutive failure count
77    consecutive_failures: u32,
78    /// Total successful checks
79    total_successes: u64,
80    /// Total failed checks
81    total_failures: u64,
82}
83
84impl ReattestState {
85    /// Create a new re-attestation state tracker.
86    pub fn new(config: ReattestConfig) -> Self {
87        Self {
88            config,
89            boot_time: Instant::now(),
90            last_success: None,
91            last_check: None,
92            consecutive_failures: 0,
93            total_successes: 0,
94            total_failures: 0,
95        }
96    }
97
98    /// Check if a re-attestation check is due now.
99    pub fn is_check_due(&self) -> bool {
100        if !self.config.enabled {
101            return false;
102        }
103
104        // Respect grace period after boot
105        if self.boot_time.elapsed() < Duration::from_secs(self.config.grace_period_secs) {
106            return false;
107        }
108
109        match self.last_check {
110            None => true, // Never checked yet
111            Some(last) => last.elapsed() >= Duration::from_secs(self.config.interval_secs),
112        }
113    }
114
115    /// Record a successful re-attestation.
116    pub fn record_success(&mut self) {
117        let now = Instant::now();
118        self.last_success = Some(now);
119        self.last_check = Some(now);
120        self.consecutive_failures = 0;
121        self.total_successes += 1;
122    }
123
124    /// Record a failed re-attestation. Returns the action to take.
125    pub fn record_failure(&mut self) -> FailureAction {
126        let now = Instant::now();
127        self.last_check = Some(now);
128        self.consecutive_failures += 1;
129        self.total_failures += 1;
130
131        if self.consecutive_failures >= self.config.max_failures {
132            self.config.failure_action
133        } else {
134            FailureAction::Warn
135        }
136    }
137
138    /// Get the number of consecutive failures.
139    pub fn consecutive_failures(&self) -> u32 {
140        self.consecutive_failures
141    }
142
143    /// Get the total number of successful checks.
144    pub fn total_successes(&self) -> u64 {
145        self.total_successes
146    }
147
148    /// Get the total number of failed checks.
149    pub fn total_failures(&self) -> u64 {
150        self.total_failures
151    }
152
153    /// Get the time since last successful attestation.
154    pub fn time_since_last_success(&self) -> Option<Duration> {
155        self.last_success.map(|t| t.elapsed())
156    }
157
158    /// Get the time since last check (success or failure).
159    pub fn time_since_last_check(&self) -> Option<Duration> {
160        self.last_check.map(|t| t.elapsed())
161    }
162
163    /// Whether the VM has ever been successfully attested.
164    pub fn has_attested(&self) -> bool {
165        self.last_success.is_some()
166    }
167
168    /// Whether the failure threshold has been exceeded.
169    pub fn is_failed(&self) -> bool {
170        self.consecutive_failures >= self.config.max_failures
171    }
172
173    /// Get the configured failure action.
174    pub fn failure_action(&self) -> FailureAction {
175        self.config.failure_action
176    }
177
178    /// Get a summary of the re-attestation state.
179    pub fn summary(&self) -> ReattestSummary {
180        ReattestSummary {
181            enabled: self.config.enabled,
182            interval_secs: self.config.interval_secs,
183            consecutive_failures: self.consecutive_failures,
184            max_failures: self.config.max_failures,
185            total_successes: self.total_successes,
186            total_failures: self.total_failures,
187            is_failed: self.is_failed(),
188            failure_action: self.config.failure_action,
189        }
190    }
191}
192
193/// Serializable summary of re-attestation state.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ReattestSummary {
196    pub enabled: bool,
197    pub interval_secs: u64,
198    pub consecutive_failures: u32,
199    pub max_failures: u32,
200    pub total_successes: u64,
201    pub total_failures: u64,
202    pub is_failed: bool,
203    pub failure_action: FailureAction,
204}
205
206/// Validate a re-attestation configuration.
207pub fn validate_config(config: &ReattestConfig) -> Result<()> {
208    if config.enabled && config.interval_secs == 0 {
209        return Err(BoxError::Other(
210            "Re-attestation interval must be > 0 seconds".to_string(),
211        ));
212    }
213    if config.enabled && config.max_failures == 0 {
214        return Err(BoxError::Other(
215            "Re-attestation max_failures must be > 0".to_string(),
216        ));
217    }
218    Ok(())
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_reattest_config_default() {
227        let config = ReattestConfig::default();
228        assert!(!config.enabled);
229        assert_eq!(config.interval_secs, 300);
230        assert_eq!(config.max_failures, 3);
231        assert_eq!(config.failure_action, FailureAction::Warn);
232        assert_eq!(config.grace_period_secs, 60);
233    }
234
235    #[test]
236    fn test_reattest_config_serde_roundtrip() {
237        let config = ReattestConfig {
238            enabled: true,
239            interval_secs: 120,
240            max_failures: 5,
241            failure_action: FailureAction::Stop,
242            grace_period_secs: 30,
243        };
244        let json = serde_json::to_string(&config).unwrap();
245        let parsed: ReattestConfig = serde_json::from_str(&json).unwrap();
246        assert!(parsed.enabled);
247        assert_eq!(parsed.interval_secs, 120);
248        assert_eq!(parsed.max_failures, 5);
249        assert_eq!(parsed.failure_action, FailureAction::Stop);
250        assert_eq!(parsed.grace_period_secs, 30);
251    }
252
253    #[test]
254    fn test_failure_action_default() {
255        assert_eq!(FailureAction::default(), FailureAction::Warn);
256    }
257
258    #[test]
259    fn test_reattest_state_disabled() {
260        let config = ReattestConfig::default(); // enabled: false
261        let state = ReattestState::new(config);
262        assert!(!state.is_check_due());
263    }
264
265    #[test]
266    fn test_reattest_state_grace_period() {
267        let config = ReattestConfig {
268            enabled: true,
269            grace_period_secs: 3600, // 1 hour — won't expire during test
270            ..Default::default()
271        };
272        let state = ReattestState::new(config);
273        assert!(!state.is_check_due());
274    }
275
276    #[test]
277    fn test_reattest_state_first_check_due() {
278        let config = ReattestConfig {
279            enabled: true,
280            grace_period_secs: 0, // No grace period
281            ..Default::default()
282        };
283        let state = ReattestState::new(config);
284        assert!(state.is_check_due());
285    }
286
287    #[test]
288    fn test_reattest_state_record_success() {
289        let config = ReattestConfig {
290            enabled: true,
291            grace_period_secs: 0,
292            ..Default::default()
293        };
294        let mut state = ReattestState::new(config);
295
296        state.record_success();
297        assert!(state.has_attested());
298        assert_eq!(state.consecutive_failures(), 0);
299        assert_eq!(state.total_successes(), 1);
300        assert_eq!(state.total_failures(), 0);
301        assert!(!state.is_failed());
302    }
303
304    #[test]
305    fn test_reattest_state_record_failure_under_threshold() {
306        let config = ReattestConfig {
307            enabled: true,
308            grace_period_secs: 0,
309            max_failures: 3,
310            failure_action: FailureAction::Stop,
311            ..Default::default()
312        };
313        let mut state = ReattestState::new(config);
314
315        // First failure — under threshold, should warn
316        let action = state.record_failure();
317        assert_eq!(action, FailureAction::Warn);
318        assert_eq!(state.consecutive_failures(), 1);
319        assert!(!state.is_failed());
320    }
321
322    #[test]
323    fn test_reattest_state_record_failure_at_threshold() {
324        let config = ReattestConfig {
325            enabled: true,
326            grace_period_secs: 0,
327            max_failures: 2,
328            failure_action: FailureAction::Stop,
329            ..Default::default()
330        };
331        let mut state = ReattestState::new(config);
332
333        state.record_failure(); // 1
334        let action = state.record_failure(); // 2 — at threshold
335        assert_eq!(action, FailureAction::Stop);
336        assert!(state.is_failed());
337        assert_eq!(state.total_failures(), 2);
338    }
339
340    #[test]
341    fn test_reattest_state_success_resets_failures() {
342        let config = ReattestConfig {
343            enabled: true,
344            grace_period_secs: 0,
345            max_failures: 3,
346            ..Default::default()
347        };
348        let mut state = ReattestState::new(config);
349
350        state.record_failure();
351        state.record_failure();
352        assert_eq!(state.consecutive_failures(), 2);
353
354        state.record_success();
355        assert_eq!(state.consecutive_failures(), 0);
356        assert!(!state.is_failed());
357        // Total counts are preserved
358        assert_eq!(state.total_failures(), 2);
359        assert_eq!(state.total_successes(), 1);
360    }
361
362    #[test]
363    fn test_reattest_state_not_due_after_recent_check() {
364        let config = ReattestConfig {
365            enabled: true,
366            grace_period_secs: 0,
367            interval_secs: 3600, // 1 hour
368            ..Default::default()
369        };
370        let mut state = ReattestState::new(config);
371
372        assert!(state.is_check_due()); // First check
373        state.record_success();
374        assert!(!state.is_check_due()); // Just checked
375    }
376
377    #[test]
378    fn test_reattest_state_summary() {
379        let config = ReattestConfig {
380            enabled: true,
381            interval_secs: 120,
382            max_failures: 5,
383            failure_action: FailureAction::Event,
384            grace_period_secs: 0,
385        };
386        let mut state = ReattestState::new(config);
387        state.record_success();
388        state.record_failure();
389
390        let summary = state.summary();
391        assert!(summary.enabled);
392        assert_eq!(summary.interval_secs, 120);
393        assert_eq!(summary.consecutive_failures, 1);
394        assert_eq!(summary.max_failures, 5);
395        assert_eq!(summary.total_successes, 1);
396        assert_eq!(summary.total_failures, 1);
397        assert!(!summary.is_failed);
398        assert_eq!(summary.failure_action, FailureAction::Event);
399    }
400
401    #[test]
402    fn test_reattest_summary_serde() {
403        let summary = ReattestSummary {
404            enabled: true,
405            interval_secs: 300,
406            consecutive_failures: 2,
407            max_failures: 3,
408            total_successes: 10,
409            total_failures: 2,
410            is_failed: false,
411            failure_action: FailureAction::Warn,
412        };
413        let json = serde_json::to_string(&summary).unwrap();
414        let parsed: ReattestSummary = serde_json::from_str(&json).unwrap();
415        assert_eq!(parsed.consecutive_failures, 2);
416        assert_eq!(parsed.total_successes, 10);
417    }
418
419    #[test]
420    fn test_validate_config_valid() {
421        let config = ReattestConfig {
422            enabled: true,
423            interval_secs: 60,
424            max_failures: 3,
425            ..Default::default()
426        };
427        assert!(validate_config(&config).is_ok());
428    }
429
430    #[test]
431    fn test_validate_config_disabled_always_valid() {
432        let config = ReattestConfig {
433            enabled: false,
434            interval_secs: 0, // Would be invalid if enabled
435            max_failures: 0,
436            ..Default::default()
437        };
438        assert!(validate_config(&config).is_ok());
439    }
440
441    #[test]
442    fn test_validate_config_zero_interval() {
443        let config = ReattestConfig {
444            enabled: true,
445            interval_secs: 0,
446            max_failures: 3,
447            ..Default::default()
448        };
449        assert!(validate_config(&config).is_err());
450    }
451
452    #[test]
453    fn test_validate_config_zero_max_failures() {
454        let config = ReattestConfig {
455            enabled: true,
456            interval_secs: 60,
457            max_failures: 0,
458            ..Default::default()
459        };
460        assert!(validate_config(&config).is_err());
461    }
462
463    #[test]
464    fn test_time_since_last_success_none() {
465        let config = ReattestConfig::default();
466        let state = ReattestState::new(config);
467        assert!(state.time_since_last_success().is_none());
468    }
469
470    #[test]
471    fn test_time_since_last_check_none() {
472        let config = ReattestConfig::default();
473        let state = ReattestState::new(config);
474        assert!(state.time_since_last_check().is_none());
475    }
476
477    #[test]
478    fn test_time_since_last_success_some() {
479        let config = ReattestConfig {
480            enabled: true,
481            grace_period_secs: 0,
482            ..Default::default()
483        };
484        let mut state = ReattestState::new(config);
485        state.record_success();
486        let elapsed = state.time_since_last_success().unwrap();
487        assert!(elapsed < Duration::from_secs(1));
488    }
489
490    #[test]
491    fn test_failure_action_event() {
492        let config = ReattestConfig {
493            enabled: true,
494            grace_period_secs: 0,
495            max_failures: 1,
496            failure_action: FailureAction::Event,
497            ..Default::default()
498        };
499        let mut state = ReattestState::new(config);
500        let action = state.record_failure();
501        assert_eq!(action, FailureAction::Event);
502    }
503}