1use crate::facts::{AuthFact, AuthFactDelta, AuthFactReducer, RecoveryFailureReason};
22use crate::guards::RecoveryOperationType;
23use aura_core::types::identifiers::AuthorityId;
24use aura_signature::session::SessionScope;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28#[derive(Debug, Clone, Default)]
37pub struct AuthView {
38 pub active_sessions: HashMap<String, SessionInfo>,
40
41 pub pending_challenges: HashMap<String, ChallengeInfo>,
43
44 pub pending_recoveries: HashMap<String, RecoveryInfo>,
46
47 pub guardian_approvals: HashMap<String, Vec<AuthorityId>>,
49
50 pub recent_failures: Vec<FailureRecord>,
52
53 pub recent_recovery_failures: Vec<RecoveryFailureRecord>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SessionInfo {
60 pub session_id: String,
62 pub authority_id: AuthorityId,
64 pub scope: SessionScope,
66 pub issued_at_ms: u64,
68 pub expires_at_ms: u64,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ChallengeInfo {
75 pub session_id: String,
77 pub authority_id: AuthorityId,
79 pub expires_at_ms: u64,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RecoveryInfo {
86 pub request_id: String,
88 pub account_id: AuthorityId,
90 pub requester_id: AuthorityId,
92 pub operation_type: RecoveryOperationType,
94 pub required_guardians: u32,
96 pub approval_count: u32,
98 pub approvers: Vec<AuthorityId>,
100 pub is_emergency: bool,
102 pub expires_at_ms: u64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct RecoveryFailureRecord {
109 pub request_id: String,
111 pub account_id: Option<AuthorityId>,
113 pub reason: RecoveryFailureReason,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FailureRecord {
120 pub session_id: String,
122 pub authority_id: AuthorityId,
124 pub reason: AuthFailureReason,
126 pub failed_at_ms: u64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub enum AuthFailureReason {
132 VerificationFailed { reason: String },
133}
134
135impl AuthView {
136 pub fn new() -> Self {
138 Self::default()
139 }
140
141 pub fn is_session_active(&self, session_id: &str, now_ms: u64) -> bool {
143 self.active_sessions
144 .get(session_id)
145 .map(|s| s.expires_at_ms > now_ms)
146 .unwrap_or(false)
147 }
148
149 pub fn sessions_for_authority(&self, authority_id: AuthorityId) -> Vec<&SessionInfo> {
151 self.active_sessions
152 .values()
153 .filter(|s| s.authority_id == authority_id)
154 .collect()
155 }
156
157 pub fn is_recovery_approved(&self, request_id: &str) -> bool {
159 self.pending_recoveries
160 .get(request_id)
161 .is_some_and(|r| r.approval_count >= r.required_guardians)
162 }
163
164 pub fn get_expired_sessions(&self, now_ms: u64) -> Vec<String> {
166 self.active_sessions
167 .iter()
168 .filter(|(_, s)| s.expires_at_ms <= now_ms)
169 .map(|(id, _)| id.clone())
170 .collect()
171 }
172
173 pub fn get_expired_challenges(&self, now_ms: u64) -> Vec<String> {
175 self.pending_challenges
176 .iter()
177 .filter(|(_, c)| c.expires_at_ms <= now_ms)
178 .map(|(id, _)| id.clone())
179 .collect()
180 }
181
182 pub fn get_expired_recoveries(&self, now_ms: u64) -> Vec<String> {
184 self.pending_recoveries
185 .iter()
186 .filter(|(_, r)| r.expires_at_ms <= now_ms)
187 .map(|(id, _)| id.clone())
188 .collect()
189 }
190}
191
192#[derive(Debug, Clone, Default)]
200pub struct AuthViewReducer {
201 fact_reducer: AuthFactReducer,
202}
203
204impl AuthViewReducer {
205 pub fn new() -> Self {
207 Self {
208 fact_reducer: AuthFactReducer::new(),
209 }
210 }
211
212 pub fn apply(&self, view: &mut AuthView, fact: &AuthFact) {
214 let delta = self.fact_reducer.reduce(fact);
215 self.apply_delta(view, delta, fact);
216 }
217
218 fn apply_delta(&self, view: &mut AuthView, delta: AuthFactDelta, fact: &AuthFact) {
220 match delta {
221 AuthFactDelta::NoChange => {}
222
223 AuthFactDelta::PendingChallenge {
224 session_id,
225 authority_id,
226 expires_at_ms,
227 } => {
228 view.pending_challenges.insert(
229 session_id.clone(),
230 ChallengeInfo {
231 session_id,
232 authority_id,
233 expires_at_ms,
234 },
235 );
236 }
237
238 AuthFactDelta::ActiveSession {
239 session_id,
240 authority_id,
241 scope,
242 expires_at_ms,
243 } => {
244 view.pending_challenges.remove(&session_id);
246
247 let issued_at_ms = fact.timestamp_ms();
249
250 view.active_sessions.insert(
252 session_id.clone(),
253 SessionInfo {
254 session_id,
255 authority_id,
256 scope,
257 issued_at_ms,
258 expires_at_ms,
259 },
260 );
261 }
262
263 AuthFactDelta::SessionRemoved { session_id } => {
264 view.active_sessions.remove(&session_id);
265 }
266
267 AuthFactDelta::PendingRecovery {
268 request_id,
269 account_id,
270 required_guardians,
271 expires_at_ms,
272 ..
273 } => {
274 let (requester_id, operation_type, is_emergency) =
276 if let AuthFact::GuardianApprovalRequested {
277 requester_id,
278 operation_type,
279 is_emergency,
280 ..
281 } = fact
282 {
283 (*requester_id, operation_type.clone(), *is_emergency)
284 } else {
285 (
286 account_id,
287 RecoveryOperationType::AccountAccessRecovery,
288 false,
289 )
290 };
291
292 view.pending_recoveries.insert(
293 request_id.clone(),
294 RecoveryInfo {
295 request_id,
296 account_id,
297 requester_id,
298 operation_type,
299 required_guardians,
300 approval_count: 0,
301 approvers: vec![],
302 is_emergency,
303 expires_at_ms,
304 },
305 );
306 }
307
308 AuthFactDelta::GuardianApprovalAdded {
309 request_id,
310 guardian_id,
311 } => {
312 if let Some(recovery) = view.pending_recoveries.get_mut(&request_id) {
314 if !recovery.approvers.contains(&guardian_id) {
315 recovery.approvers.push(guardian_id);
316 recovery.approval_count += 1;
317 }
318 }
319
320 view.guardian_approvals
322 .entry(request_id)
323 .or_default()
324 .push(guardian_id);
325 }
326
327 AuthFactDelta::RecoveryCompleted { request_id } => {
328 view.pending_recoveries.remove(&request_id);
329 }
330
331 AuthFactDelta::RecoveryFailed { request_id, reason } => {
332 let account_id = view
333 .pending_recoveries
334 .get(&request_id)
335 .map(|r| r.account_id);
336 view.recent_recovery_failures.push(RecoveryFailureRecord {
337 request_id: request_id.clone(),
338 account_id,
339 reason,
340 });
341 view.pending_recoveries.remove(&request_id);
342 }
343 }
344
345 if let AuthFact::AuthFailed {
347 session_id,
348 authority_id,
349 reason,
350 failed_at_ms,
351 ..
352 } = fact
353 {
354 view.recent_failures.push(FailureRecord {
355 session_id: session_id.clone(),
356 authority_id: *authority_id,
357 reason: AuthFailureReason::VerificationFailed {
358 reason: reason.clone(),
359 },
360 failed_at_ms: *failed_at_ms,
361 });
362
363 if view.recent_failures.len() > 100 {
365 view.recent_failures.remove(0);
366 }
367 }
368 }
369
370 pub fn reduce_all(&self, facts: &[AuthFact]) -> AuthView {
372 let mut view = AuthView::new();
373 for fact in facts {
374 self.apply(&mut view, fact);
375 }
376 view
377 }
378}
379
380#[cfg(test)]
385mod tests {
386 use super::*;
387 use aura_core::ContextId;
388 use aura_signature::session::SessionScope;
389
390 fn test_authority() -> AuthorityId {
391 AuthorityId::new_from_entropy([1u8; 32])
392 }
393
394 fn test_authority_2() -> AuthorityId {
395 AuthorityId::new_from_entropy([2u8; 32])
396 }
397
398 fn test_context_id() -> ContextId {
399 ContextId::new_from_entropy([9u8; 32])
400 }
401
402 #[test]
403 fn test_auth_view_new() {
404 let view = AuthView::new();
405 assert!(view.active_sessions.is_empty());
406 assert!(view.pending_challenges.is_empty());
407 assert!(view.pending_recoveries.is_empty());
408 }
409
410 #[test]
413 fn test_session_lifecycle() {
414 let reducer = AuthViewReducer::new();
415 let mut view = AuthView::new();
416
417 let fact = AuthFact::SessionIssued {
419 context_id: test_context_id(),
420 session_id: "session_123".to_string(),
421 authority_id: test_authority(),
422 device_id: None,
423 scope: SessionScope::Protocol {
424 protocol_type: "test".to_string(),
425 },
426 issued_at_ms: 1000,
427 expires_at_ms: 2000,
428 };
429
430 reducer.apply(&mut view, &fact);
431 assert!(view.active_sessions.contains_key("session_123"));
432 assert!(view.is_session_active("session_123", 1500));
433 assert!(!view.is_session_active("session_123", 2500)); let revoke_fact = AuthFact::SessionRevoked {
437 context_id: test_context_id(),
438 session_id: "session_123".to_string(),
439 revoked_by: test_authority(),
440 reason: "User requested".to_string(),
441 revoked_at_ms: 1500,
442 };
443
444 reducer.apply(&mut view, &revoke_fact);
445 assert!(!view.active_sessions.contains_key("session_123"));
446 }
447
448 #[test]
449 fn test_recovery_approval_flow() {
450 let reducer = AuthViewReducer::new();
451 let mut view = AuthView::new();
452
453 let request_fact = AuthFact::GuardianApprovalRequested {
455 context_id: test_context_id(),
456 request_id: "recovery_123".to_string(),
457 account_id: test_authority(),
458 requester_id: test_authority(),
459 operation_type: RecoveryOperationType::DeviceKeyRecovery,
460 required_guardians: 2,
461 is_emergency: false,
462 justification: "Lost device".to_string(),
463 requested_at_ms: 1000,
464 expires_at_ms: 86400000,
465 };
466
467 reducer.apply(&mut view, &request_fact);
468 assert!(view.pending_recoveries.contains_key("recovery_123"));
469 assert!(!view.is_recovery_approved("recovery_123"));
470
471 let approve1 = AuthFact::GuardianApproved {
473 context_id: test_context_id(),
474 request_id: "recovery_123".to_string(),
475 guardian_id: test_authority(),
476 signature: vec![0u8; 64],
477 justification: "Approved".to_string(),
478 approved_at_ms: 2000,
479 };
480
481 reducer.apply(&mut view, &approve1);
482 assert_eq!(
483 view.pending_recoveries
484 .get("recovery_123")
485 .map(|r| r.approval_count),
486 Some(1)
487 );
488 assert!(!view.is_recovery_approved("recovery_123"));
489
490 let approve2 = AuthFact::GuardianApproved {
492 context_id: test_context_id(),
493 request_id: "recovery_123".to_string(),
494 guardian_id: test_authority_2(),
495 signature: vec![0u8; 64],
496 justification: "Approved".to_string(),
497 approved_at_ms: 3000,
498 };
499
500 reducer.apply(&mut view, &approve2);
501 assert_eq!(
502 view.pending_recoveries
503 .get("recovery_123")
504 .map(|r| r.approval_count),
505 Some(2)
506 );
507 assert!(view.is_recovery_approved("recovery_123"));
508 }
509
510 #[test]
511 fn test_failure_tracking() {
512 let reducer = AuthViewReducer::new();
513 let mut view = AuthView::new();
514
515 let fail_fact = AuthFact::AuthFailed {
516 context_id: test_context_id(),
517 session_id: "session_456".to_string(),
518 authority_id: test_authority(),
519 reason: "Invalid signature".to_string(),
520 failed_at_ms: 1000,
521 };
522
523 reducer.apply(&mut view, &fail_fact);
524 assert_eq!(view.recent_failures.len(), 1);
525 assert_eq!(view.recent_failures[0].session_id, "session_456");
526 assert_eq!(
527 view.recent_failures[0].reason,
528 AuthFailureReason::VerificationFailed {
529 reason: "Invalid signature".to_string(),
530 }
531 );
532 }
533
534 #[test]
535 fn test_recovery_failure_tracking() {
536 let reducer = AuthViewReducer::new();
537 let mut view = AuthView::new();
538
539 let request_fact = AuthFact::GuardianApprovalRequested {
540 context_id: test_context_id(),
541 request_id: "recovery_789".to_string(),
542 account_id: test_authority(),
543 requester_id: test_authority_2(),
544 operation_type: RecoveryOperationType::AccountAccessRecovery,
545 required_guardians: 2,
546 is_emergency: false,
547 justification: "Need recovery".to_string(),
548 requested_at_ms: 1000,
549 expires_at_ms: 5000,
550 };
551 reducer.apply(&mut view, &request_fact);
552
553 let fail_fact = AuthFact::GuardianDenied {
554 context_id: test_context_id(),
555 request_id: "recovery_789".to_string(),
556 guardian_id: test_authority_2(),
557 reason: "guardian denied".to_string(),
558 denied_at_ms: 1500,
559 };
560 reducer.apply(&mut view, &fail_fact);
561
562 assert!(!view.pending_recoveries.contains_key("recovery_789"));
563 assert_eq!(view.recent_recovery_failures.len(), 1);
564 assert_eq!(
565 view.recent_recovery_failures[0].reason,
566 RecoveryFailureReason::GuardianDenied {
567 reason: "guardian denied".to_string(),
568 }
569 );
570 }
571
572 #[test]
575 fn test_expired_session_detection() {
576 let view = AuthView {
577 active_sessions: {
578 let mut map = HashMap::new();
579 map.insert(
580 "session_old".to_string(),
581 SessionInfo {
582 session_id: "session_old".to_string(),
583 authority_id: test_authority(),
584 scope: SessionScope::Protocol {
585 protocol_type: "test".to_string(),
586 },
587 issued_at_ms: 0,
588 expires_at_ms: 1000,
589 },
590 );
591 map.insert(
592 "session_new".to_string(),
593 SessionInfo {
594 session_id: "session_new".to_string(),
595 authority_id: test_authority(),
596 scope: SessionScope::Protocol {
597 protocol_type: "test".to_string(),
598 },
599 issued_at_ms: 500,
600 expires_at_ms: 2000,
601 },
602 );
603 map
604 },
605 ..Default::default()
606 };
607
608 let expired = view.get_expired_sessions(1500);
609 assert_eq!(expired.len(), 1);
610 assert!(expired.contains(&"session_old".to_string()));
611 }
612
613 #[test]
614 fn test_sessions_for_authority() {
615 let view = AuthView {
616 active_sessions: {
617 let mut map = HashMap::new();
618 map.insert(
619 "session_1".to_string(),
620 SessionInfo {
621 session_id: "session_1".to_string(),
622 authority_id: test_authority(),
623 scope: SessionScope::Protocol {
624 protocol_type: "test".to_string(),
625 },
626 issued_at_ms: 0,
627 expires_at_ms: 2000,
628 },
629 );
630 map.insert(
631 "session_2".to_string(),
632 SessionInfo {
633 session_id: "session_2".to_string(),
634 authority_id: test_authority_2(),
635 scope: SessionScope::Protocol {
636 protocol_type: "test".to_string(),
637 },
638 issued_at_ms: 0,
639 expires_at_ms: 2000,
640 },
641 );
642 map
643 },
644 ..Default::default()
645 };
646
647 let sessions = view.sessions_for_authority(test_authority());
648 assert_eq!(sessions.len(), 1);
649 assert_eq!(sessions[0].session_id, "session_1");
650 }
651
652 #[test]
653 fn test_reduce_all() {
654 let reducer = AuthViewReducer::new();
655
656 let facts = vec![
657 AuthFact::SessionIssued {
658 context_id: test_context_id(),
659 session_id: "session_1".to_string(),
660 authority_id: test_authority(),
661 device_id: None,
662 scope: SessionScope::Protocol {
663 protocol_type: "test".to_string(),
664 },
665 issued_at_ms: 1000,
666 expires_at_ms: 2000,
667 },
668 AuthFact::SessionIssued {
669 context_id: test_context_id(),
670 session_id: "session_2".to_string(),
671 authority_id: test_authority_2(),
672 device_id: None,
673 scope: SessionScope::Protocol {
674 protocol_type: "test".to_string(),
675 },
676 issued_at_ms: 1500,
677 expires_at_ms: 2500,
678 },
679 ];
680
681 let view = reducer.reduce_all(&facts);
682 assert_eq!(view.active_sessions.len(), 2);
683 }
684
685 #[test]
688 fn test_reduce_all_commutes_for_disjoint_sessions() {
689 let reducer = AuthViewReducer::new();
690 let fact_a = AuthFact::SessionIssued {
691 context_id: test_context_id(),
692 session_id: "session_a".to_string(),
693 authority_id: test_authority(),
694 device_id: None,
695 scope: SessionScope::Protocol {
696 protocol_type: "test".to_string(),
697 },
698 issued_at_ms: 1000,
699 expires_at_ms: 2000,
700 };
701 let fact_b = AuthFact::SessionIssued {
702 context_id: test_context_id(),
703 session_id: "session_b".to_string(),
704 authority_id: test_authority_2(),
705 device_id: None,
706 scope: SessionScope::Protocol {
707 protocol_type: "test".to_string(),
708 },
709 issued_at_ms: 1000,
710 expires_at_ms: 2000,
711 };
712
713 let view1 = reducer.reduce_all(&[fact_a.clone(), fact_b.clone()]);
714 let view2 = reducer.reduce_all(&[fact_b, fact_a]);
715
716 assert_eq!(view1.active_sessions.len(), view2.active_sessions.len());
717 assert!(view1.active_sessions.contains_key("session_a"));
718 assert!(view1.active_sessions.contains_key("session_b"));
719 assert!(view2.active_sessions.contains_key("session_a"));
720 assert!(view2.active_sessions.contains_key("session_b"));
721 }
722}