Skip to main content

peat_protocol/security/
auth_state.rs

1//! Authentication state machine for certificate expiration tracking.
2//!
3//! Implements graceful degradation per ADR-048:
4//! - Valid → Warning → GracePeriod → Expired
5//! - Configurable intervals and thresholds
6
7use super::MembershipCertificate;
8
9/// Certificate validity state with remaining time.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CertificateState {
12    /// Certificate is valid with time remaining until expiration.
13    Valid {
14        /// Milliseconds until expiration.
15        expires_in_ms: u64,
16    },
17    /// Certificate approaching expiration (within warning threshold).
18    Warning {
19        /// Milliseconds until expiration.
20        expires_in_ms: u64,
21    },
22    /// Certificate expired but within grace period.
23    GracePeriod {
24        /// Milliseconds remaining in grace period.
25        grace_remaining_ms: u64,
26    },
27    /// Certificate expired and grace period exhausted.
28    Expired,
29}
30
31impl CertificateState {
32    /// Returns true if the certificate allows mesh operations.
33    pub fn is_operational(&self) -> bool {
34        matches!(
35            self,
36            CertificateState::Valid { .. }
37                | CertificateState::Warning { .. }
38                | CertificateState::GracePeriod { .. }
39        )
40    }
41
42    /// Returns true if re-authentication should be initiated.
43    pub fn should_reauth(&self) -> bool {
44        matches!(
45            self,
46            CertificateState::Warning { .. } | CertificateState::GracePeriod { .. }
47        )
48    }
49
50    /// Returns true if the certificate is fully expired.
51    pub fn is_expired(&self) -> bool {
52        matches!(self, CertificateState::Expired)
53    }
54}
55
56/// Configuration for authentication intervals and thresholds.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct AuthConfig {
59    /// Certificate validity period in hours. Default: 24.
60    pub auth_interval_hours: u16,
61    /// Grace period after expiration in hours. Default: 4.
62    pub grace_period_hours: u16,
63    /// Warning threshold before expiration in hours. Default: 1.
64    pub warning_threshold_hours: u16,
65}
66
67impl Default for AuthConfig {
68    fn default() -> Self {
69        Self {
70            auth_interval_hours: 24,
71            grace_period_hours: 4,
72            warning_threshold_hours: 1,
73        }
74    }
75}
76
77impl AuthConfig {
78    /// Create config with custom intervals.
79    pub fn new(
80        auth_interval_hours: u16,
81        grace_period_hours: u16,
82        warning_threshold_hours: u16,
83    ) -> Self {
84        Self {
85            auth_interval_hours,
86            grace_period_hours,
87            warning_threshold_hours,
88        }
89    }
90
91    /// Auth interval in milliseconds.
92    pub fn auth_interval_ms(&self) -> u64 {
93        self.auth_interval_hours as u64 * 3_600_000
94    }
95
96    /// Grace period in milliseconds.
97    pub fn grace_period_ms(&self) -> u64 {
98        self.grace_period_hours as u64 * 3_600_000
99    }
100
101    /// Warning threshold in milliseconds.
102    pub fn warning_threshold_ms(&self) -> u64 {
103        self.warning_threshold_hours as u64 * 3_600_000
104    }
105}
106
107/// Tracks authentication state for membership certificates.
108#[derive(Debug, Clone)]
109pub struct AuthStateTracker {
110    config: AuthConfig,
111}
112
113impl Default for AuthStateTracker {
114    fn default() -> Self {
115        Self::new(AuthConfig::default())
116    }
117}
118
119impl AuthStateTracker {
120    /// Create a new tracker with the given configuration.
121    pub fn new(config: AuthConfig) -> Self {
122        Self { config }
123    }
124
125    /// Get the current configuration.
126    pub fn config(&self) -> &AuthConfig {
127        &self.config
128    }
129
130    /// Check the state of a certificate at the given time.
131    ///
132    /// # Timeline
133    /// ```text
134    /// ├── T-0: Certificate issued
135    /// ├── T-(auth_interval - warning_threshold): Warning state
136    /// ├── T-auth_interval: Expiration (grace period starts)
137    /// ├── T-(auth_interval + grace_period): Hard cutoff (Expired)
138    /// └── Re-auth succeeds: New cert, timer resets
139    /// ```
140    pub fn check_state(&self, cert: &MembershipCertificate, now_ms: u64) -> CertificateState {
141        let expires_at = cert.expires_at_ms;
142
143        if now_ms < expires_at {
144            // Certificate has not yet expired
145            let expires_in_ms = expires_at - now_ms;
146
147            if expires_in_ms <= self.config.warning_threshold_ms() {
148                CertificateState::Warning { expires_in_ms }
149            } else {
150                CertificateState::Valid { expires_in_ms }
151            }
152        } else {
153            // Certificate has expired
154            let expired_for_ms = now_ms - expires_at;
155
156            if expired_for_ms < self.config.grace_period_ms() {
157                let grace_remaining_ms = self.config.grace_period_ms() - expired_for_ms;
158                CertificateState::GracePeriod { grace_remaining_ms }
159            } else {
160                CertificateState::Expired
161            }
162        }
163    }
164
165    /// Check if a certificate needs re-authentication.
166    ///
167    /// Returns true if the certificate is in Warning or GracePeriod state.
168    pub fn needs_reauth(&self, cert: &MembershipCertificate, now_ms: u64) -> bool {
169        self.check_state(cert, now_ms).should_reauth()
170    }
171
172    /// Check if a certificate is still operational (allows mesh operations).
173    ///
174    /// Returns true for Valid, Warning, and GracePeriod states.
175    pub fn is_operational(&self, cert: &MembershipCertificate, now_ms: u64) -> bool {
176        self.check_state(cert, now_ms).is_operational()
177    }
178
179    /// Check if a certificate has fully expired (no grace period remaining).
180    pub fn is_expired(&self, cert: &MembershipCertificate, now_ms: u64) -> bool {
181        self.check_state(cert, now_ms).is_expired()
182    }
183
184    /// Calculate when re-authentication should begin (warning threshold).
185    pub fn reauth_deadline(&self, cert: &MembershipCertificate) -> u64 {
186        cert.expires_at_ms
187            .saturating_sub(self.config.warning_threshold_ms())
188    }
189
190    /// Calculate the hard cutoff time (grace period exhausted).
191    pub fn hard_cutoff(&self, cert: &MembershipCertificate) -> u64 {
192        cert.expires_at_ms
193            .saturating_add(self.config.grace_period_ms())
194    }
195}
196
197/// Event emitted on state transitions.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum AuthStateEvent {
200    /// Transitioned from Valid to Warning.
201    EnteringWarning { expires_in_ms: u64 },
202    /// Transitioned from Warning to GracePeriod.
203    EnteringGracePeriod { grace_remaining_ms: u64 },
204    /// Transitioned to Expired (hard cutoff).
205    Expired,
206    /// Re-authenticated successfully, back to Valid.
207    Renewed { new_expires_at_ms: u64 },
208}
209
210/// Monitors certificates and emits state transition events.
211#[derive(Debug, Clone)]
212pub struct AuthStateMonitor {
213    tracker: AuthStateTracker,
214    last_state: Option<CertificateState>,
215}
216
217impl AuthStateMonitor {
218    /// Create a new monitor with the given tracker.
219    pub fn new(tracker: AuthStateTracker) -> Self {
220        Self {
221            tracker,
222            last_state: None,
223        }
224    }
225
226    /// Update the monitor with current time and check for state transitions.
227    ///
228    /// Returns an event if the state changed.
229    pub fn update(&mut self, cert: &MembershipCertificate, now_ms: u64) -> Option<AuthStateEvent> {
230        let new_state = self.tracker.check_state(cert, now_ms);
231
232        let event = match (&self.last_state, &new_state) {
233            // Valid → Warning
234            (Some(CertificateState::Valid { .. }), CertificateState::Warning { expires_in_ms })
235            | (None, CertificateState::Warning { expires_in_ms }) => {
236                Some(AuthStateEvent::EnteringWarning {
237                    expires_in_ms: *expires_in_ms,
238                })
239            }
240
241            // Warning → GracePeriod
242            (
243                Some(CertificateState::Warning { .. }),
244                CertificateState::GracePeriod { grace_remaining_ms },
245            ) => Some(AuthStateEvent::EnteringGracePeriod {
246                grace_remaining_ms: *grace_remaining_ms,
247            }),
248
249            // Any → Expired
250            (Some(state), CertificateState::Expired) if *state != CertificateState::Expired => {
251                Some(AuthStateEvent::Expired)
252            }
253
254            _ => None,
255        };
256
257        self.last_state = Some(new_state);
258        event
259    }
260
261    /// Notify the monitor that re-authentication succeeded.
262    ///
263    /// Call this after a new certificate is obtained.
264    pub fn notify_renewed(&mut self, new_cert: &MembershipCertificate) -> AuthStateEvent {
265        self.last_state = Some(CertificateState::Valid {
266            expires_in_ms: new_cert.expires_at_ms,
267        });
268        AuthStateEvent::Renewed {
269            new_expires_at_ms: new_cert.expires_at_ms,
270        }
271    }
272
273    /// Get the current state without triggering events.
274    pub fn current_state(&self) -> Option<&CertificateState> {
275        self.last_state.as_ref()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    // Helper to create a test certificate
284    fn test_cert(issued_at_ms: u64, expires_at_ms: u64) -> MembershipCertificate {
285        MembershipCertificate {
286            member_public_key: [0u8; 32],
287            mesh_id: "A1B2C3D4".to_string(),
288            callsign: "TEST-01".to_string(),
289            permissions: super::super::MemberPermissions::STANDARD,
290            issued_at_ms,
291            expires_at_ms,
292            issuer_public_key: [0u8; 32],
293            issuer_signature: [0u8; 64],
294        }
295    }
296
297    #[test]
298    fn test_config_defaults() {
299        let config = AuthConfig::default();
300        assert_eq!(config.auth_interval_hours, 24);
301        assert_eq!(config.grace_period_hours, 4);
302        assert_eq!(config.warning_threshold_hours, 1);
303    }
304
305    #[test]
306    fn test_config_to_ms() {
307        let config = AuthConfig::default();
308        assert_eq!(config.auth_interval_ms(), 24 * 3_600_000);
309        assert_eq!(config.grace_period_ms(), 4 * 3_600_000);
310        assert_eq!(config.warning_threshold_ms(), 3_600_000);
311    }
312
313    #[test]
314    fn test_valid_state() {
315        let tracker = AuthStateTracker::default();
316        let cert = test_cert(0, 24 * 3_600_000); // Expires in 24h
317
318        // At T=0, 24h remaining
319        let state = tracker.check_state(&cert, 0);
320        assert!(
321            matches!(state, CertificateState::Valid { expires_in_ms } if expires_in_ms == 24 * 3_600_000)
322        );
323        assert!(state.is_operational());
324        assert!(!state.should_reauth());
325
326        // At T=12h, 12h remaining
327        let state = tracker.check_state(&cert, 12 * 3_600_000);
328        assert!(
329            matches!(state, CertificateState::Valid { expires_in_ms } if expires_in_ms == 12 * 3_600_000)
330        );
331    }
332
333    #[test]
334    fn test_warning_state() {
335        let tracker = AuthStateTracker::default();
336        let cert = test_cert(0, 24 * 3_600_000);
337
338        // At T=23h, 1h remaining (within warning threshold)
339        let state = tracker.check_state(&cert, 23 * 3_600_000);
340        assert!(
341            matches!(state, CertificateState::Warning { expires_in_ms } if expires_in_ms == 3_600_000)
342        );
343        assert!(state.is_operational());
344        assert!(state.should_reauth());
345
346        // At T=23.5h, 30min remaining
347        let state = tracker.check_state(&cert, 23 * 3_600_000 + 1_800_000);
348        assert!(
349            matches!(state, CertificateState::Warning { expires_in_ms } if expires_in_ms == 1_800_000)
350        );
351    }
352
353    #[test]
354    fn test_grace_period_state() {
355        let tracker = AuthStateTracker::default();
356        let cert = test_cert(0, 24 * 3_600_000);
357
358        // At T=24h, just expired (4h grace remaining)
359        let state = tracker.check_state(&cert, 24 * 3_600_000);
360        assert!(
361            matches!(state, CertificateState::GracePeriod { grace_remaining_ms } if grace_remaining_ms == 4 * 3_600_000)
362        );
363        assert!(state.is_operational());
364        assert!(state.should_reauth());
365
366        // At T=26h, 2h into grace (2h remaining)
367        let state = tracker.check_state(&cert, 26 * 3_600_000);
368        assert!(
369            matches!(state, CertificateState::GracePeriod { grace_remaining_ms } if grace_remaining_ms == 2 * 3_600_000)
370        );
371    }
372
373    #[test]
374    fn test_expired_state() {
375        let tracker = AuthStateTracker::default();
376        let cert = test_cert(0, 24 * 3_600_000);
377
378        // At T=28h, grace period exhausted
379        let state = tracker.check_state(&cert, 28 * 3_600_000);
380        assert!(matches!(state, CertificateState::Expired));
381        assert!(!state.is_operational());
382        assert!(!state.should_reauth()); // Too late to reauth
383
384        // At T=30h, still expired
385        let state = tracker.check_state(&cert, 30 * 3_600_000);
386        assert!(matches!(state, CertificateState::Expired));
387    }
388
389    #[test]
390    fn test_needs_reauth() {
391        let tracker = AuthStateTracker::default();
392        let cert = test_cert(0, 24 * 3_600_000);
393
394        // Valid: no reauth needed
395        assert!(!tracker.needs_reauth(&cert, 0));
396        assert!(!tracker.needs_reauth(&cert, 22 * 3_600_000));
397
398        // Warning: reauth needed
399        assert!(tracker.needs_reauth(&cert, 23 * 3_600_000));
400        assert!(tracker.needs_reauth(&cert, 23 * 3_600_000 + 1_800_000));
401
402        // Grace period: reauth needed
403        assert!(tracker.needs_reauth(&cert, 25 * 3_600_000));
404
405        // Expired: too late
406        assert!(!tracker.needs_reauth(&cert, 29 * 3_600_000));
407    }
408
409    #[test]
410    fn test_is_operational() {
411        let tracker = AuthStateTracker::default();
412        let cert = test_cert(0, 24 * 3_600_000);
413
414        assert!(tracker.is_operational(&cert, 0));
415        assert!(tracker.is_operational(&cert, 23 * 3_600_000)); // Warning
416        assert!(tracker.is_operational(&cert, 26 * 3_600_000)); // Grace
417        assert!(!tracker.is_operational(&cert, 29 * 3_600_000)); // Expired
418    }
419
420    #[test]
421    fn test_deadlines() {
422        let tracker = AuthStateTracker::default();
423        let cert = test_cert(0, 24 * 3_600_000);
424
425        // Reauth deadline = expires - warning_threshold = 24h - 1h = 23h
426        assert_eq!(tracker.reauth_deadline(&cert), 23 * 3_600_000);
427
428        // Hard cutoff = expires + grace_period = 24h + 4h = 28h
429        assert_eq!(tracker.hard_cutoff(&cert), 28 * 3_600_000);
430    }
431
432    #[test]
433    fn test_custom_config() {
434        let config = AuthConfig::new(48, 8, 2); // 48h validity, 8h grace, 2h warning
435        let tracker = AuthStateTracker::new(config);
436        let cert = test_cert(0, 48 * 3_600_000);
437
438        // At T=45h, still valid (3h remaining, warning at 2h)
439        let state = tracker.check_state(&cert, 45 * 3_600_000);
440        assert!(matches!(state, CertificateState::Valid { .. }));
441
442        // At T=46.5h, warning (1.5h remaining)
443        let state = tracker.check_state(&cert, 46 * 3_600_000 + 1_800_000);
444        assert!(matches!(state, CertificateState::Warning { .. }));
445
446        // At T=52h, grace period (4h into 8h grace)
447        let state = tracker.check_state(&cert, 52 * 3_600_000);
448        assert!(
449            matches!(state, CertificateState::GracePeriod { grace_remaining_ms } if grace_remaining_ms == 4 * 3_600_000)
450        );
451
452        // At T=56h, expired (grace exhausted)
453        let state = tracker.check_state(&cert, 56 * 3_600_000);
454        assert!(matches!(state, CertificateState::Expired));
455    }
456
457    #[test]
458    fn test_monitor_transitions() {
459        let tracker = AuthStateTracker::default();
460        let mut monitor = AuthStateMonitor::new(tracker);
461        let cert = test_cert(0, 24 * 3_600_000);
462
463        // Initial check at T=0 (Valid)
464        let event = monitor.update(&cert, 0);
465        assert!(event.is_none()); // No transition from None → Valid
466
467        // At T=22h, still valid
468        let event = monitor.update(&cert, 22 * 3_600_000);
469        assert!(event.is_none());
470
471        // At T=23h, Valid → Warning
472        let event = monitor.update(&cert, 23 * 3_600_000);
473        assert!(matches!(
474            event,
475            Some(AuthStateEvent::EnteringWarning { .. })
476        ));
477
478        // At T=24h, Warning → GracePeriod
479        let event = monitor.update(&cert, 24 * 3_600_000);
480        assert!(matches!(
481            event,
482            Some(AuthStateEvent::EnteringGracePeriod { .. })
483        ));
484
485        // At T=28h, GracePeriod → Expired
486        let event = monitor.update(&cert, 28 * 3_600_000);
487        assert!(matches!(event, Some(AuthStateEvent::Expired)));
488
489        // At T=30h, still Expired (no new event)
490        let event = monitor.update(&cert, 30 * 3_600_000);
491        assert!(event.is_none());
492    }
493
494    #[test]
495    fn test_monitor_renewal() {
496        let tracker = AuthStateTracker::default();
497        let mut monitor = AuthStateMonitor::new(tracker);
498        let cert = test_cert(0, 24 * 3_600_000);
499
500        // Get into warning state
501        monitor.update(&cert, 23 * 3_600_000);
502
503        // Simulate re-auth with new cert
504        let new_cert = test_cert(23 * 3_600_000, 47 * 3_600_000);
505        let event = monitor.notify_renewed(&new_cert);
506        assert!(matches!(
507            event,
508            AuthStateEvent::Renewed {
509                new_expires_at_ms: exp
510            } if exp == 47 * 3_600_000
511        ));
512
513        // Now monitoring the new cert, should be valid
514        let state = monitor.current_state();
515        assert!(matches!(state, Some(CertificateState::Valid { .. })));
516    }
517}