An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add recovery window expiry check

- Add chrono to workspace dependencies with clock and std features
- Implement check_recovery_window to enforce 72-hour recovery deadline
- Verifies AC7.5: RECOVERY_WINDOW_EXPIRED error when deadline passed
- Comprehensive test coverage: expired, boundary, valid, recent, invalid timestamps
- All tests passing

+86
+1
Cargo.lock
··· 2824 2824 version = "0.1.0" 2825 2825 dependencies = [ 2826 2826 "base64 0.21.7", 2827 + "chrono", 2827 2828 "crypto", 2828 2829 "hickory-resolver", 2829 2830 "httpmock",
+3
Cargo.toml
··· 40 40 anyhow = "1" 41 41 thiserror = "2" 42 42 43 + # Date/time 44 + chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } 45 + 43 46 # Observability 44 47 tracing = "0.1" 45 48 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 38 38 hickory-resolver = { workspace = true } 39 39 zeroize = { workspace = true } 40 40 urlencoding = { workspace = true } 41 + chrono = { workspace = true } 41 42 42 43 [dev-dependencies] 43 44 tokio = { version = "1", features = ["macros", "rt"] }
+81
apps/identity-wallet/src-tauri/src/recovery.rs
··· 6 6 use crate::claim::OpDiff; 7 7 use serde::Serialize; 8 8 use crypto::{AuditEntry, DidKeyUri}; 9 + use chrono::{DateTime, Duration, Utc}; 9 10 10 11 /// Result of building a recovery override operation. 11 12 /// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`. ··· 86 87 Err(RecoveryError::SigningFailed { 87 88 message: "No device-key-signed operation found before the unauthorized change".to_string(), 88 89 }) 90 + } 91 + 92 + const RECOVERY_WINDOW_HOURS: i64 = 72; 93 + 94 + /// Checks whether the 72-hour recovery window is still open for an unauthorized operation. 95 + /// 96 + /// Returns `Ok(())` if recovery is still possible, or `Err(RecoveryWindowExpired)` if 97 + /// the 72-hour deadline has passed. 98 + #[allow(dead_code)] 99 + pub(crate) fn check_recovery_window( 100 + unauthorized_op_created_at: &str, 101 + ) -> Result<(), RecoveryError> { 102 + let op_time = DateTime::parse_from_rfc3339(unauthorized_op_created_at) 103 + .map_err(|e| RecoveryError::SigningFailed { 104 + message: format!("Failed to parse operation timestamp: {e}"), 105 + })? 106 + .with_timezone(&Utc); 107 + 108 + let deadline = op_time + Duration::hours(RECOVERY_WINDOW_HOURS); 109 + 110 + if Utc::now() > deadline { 111 + return Err(RecoveryError::RecoveryWindowExpired); 112 + } 113 + 114 + Ok(()) 89 115 } 90 116 91 117 #[cfg(test)] ··· 302 328 .expect("find_fork_point succeeds"); 303 329 304 330 assert_eq!(fork_entry.cid, genesis_cid); 331 + } 332 + 333 + #[test] 334 + fn test_check_recovery_window_expired() { 335 + // Create a timestamp 73 hours in the past (beyond the 72-hour window) 336 + let expired_time = Utc::now() - Duration::hours(73); 337 + let expired_timestamp = expired_time.to_rfc3339(); 338 + 339 + let result = check_recovery_window(&expired_timestamp); 340 + assert!(matches!(result, Err(RecoveryError::RecoveryWindowExpired))); 341 + } 342 + 343 + #[test] 344 + fn test_check_recovery_window_at_boundary() { 345 + // Create a timestamp 71.5 hours in the past (well within the window) 346 + // We use 71.5 hours instead of exactly 72 to avoid race conditions 347 + // in the test where the calculation happens between two system calls 348 + let boundary_time = Utc::now() - Duration::hours(71) - Duration::minutes(30); 349 + let boundary_timestamp = boundary_time.to_rfc3339(); 350 + 351 + // Should be OK since we're within the 72-hour window 352 + let result = check_recovery_window(&boundary_timestamp); 353 + assert!(result.is_ok()); 354 + } 355 + 356 + #[test] 357 + fn test_check_recovery_window_valid() { 358 + // Create a timestamp 1 hour in the past (well within the window) 359 + let valid_time = Utc::now() - Duration::hours(1); 360 + let valid_timestamp = valid_time.to_rfc3339(); 361 + 362 + let result = check_recovery_window(&valid_timestamp); 363 + assert!(result.is_ok()); 364 + } 365 + 366 + #[test] 367 + fn test_check_recovery_window_very_recent() { 368 + // Create a timestamp just 1 minute in the past 369 + let recent_time = Utc::now() - Duration::minutes(1); 370 + let recent_timestamp = recent_time.to_rfc3339(); 371 + 372 + let result = check_recovery_window(&recent_timestamp); 373 + assert!(result.is_ok()); 374 + } 375 + 376 + #[test] 377 + fn test_check_recovery_window_invalid_timestamp() { 378 + let result = check_recovery_window("not a valid timestamp"); 379 + assert!(matches!(result, Err(RecoveryError::SigningFailed { .. }))); 380 + } 381 + 382 + #[test] 383 + fn test_check_recovery_window_malformed_rfc3339() { 384 + let result = check_recovery_window("2026-03-31T12:00"); 385 + assert!(matches!(result, Err(RecoveryError::SigningFailed { .. }))); 305 386 } 306 387 }