Skip to main content

aura_authentication/
service.rs

1//! Authentication Service
2//!
3//! Main coordinator for authentication operations.
4//! All operations flow through the guard chain and return outcomes
5//! for the caller to execute effects.
6//!
7//! # Architecture
8//!
9//! The `AuthService` follows the same pattern as `aura-invitation::InvitationService`:
10//!
11//! 1. Caller prepares a `GuardSnapshot` asynchronously
12//! 2. Service evaluates guards synchronously, returning `GuardOutcome`
13//! 3. Caller executes `EffectCommand` items asynchronously
14//!
15//! This separation ensures:
16//! - Guard evaluation is pure and testable
17//! - Effect execution is explicit and controllable
18//! - No I/O happens during guard evaluation
19//!
20//! # Migration from Coordinators
21//!
22//! This service replaces the legacy coordinator pattern (`DeviceAuthCoordinator`,
23//! `SessionCreationCoordinator`, `GuardianAuthCoordinator`) with a unified
24//! service that uses the guard chain pattern.
25
26use crate::capabilities::{
27    AuthenticationCapability, GuardianAuthCapability, RecoveryAuthorizationCapability,
28};
29use crate::facts::AuthFact;
30use crate::guards::{
31    check_capability, check_flow_budget, costs, EffectCommand, GuardOutcome, GuardSnapshot,
32    RecoveryContext, RecoveryOperationType,
33};
34use aura_core::hash::hash;
35use aura_core::types::identifiers::{AuthorityId, ContextId};
36use aura_journal::DomainFact;
37use aura_signature::session::SessionScope;
38use serde::{Deserialize, Serialize};
39
40#[derive(Debug, thiserror::Error)]
41enum AuthGuardError {
42    #[error("Session duration {requested}s exceeds maximum {max}s")]
43    SessionDurationTooLong { requested: u64, max: u64 },
44    #[error("Guardian set modification requires recovery:approve capability")]
45    GuardianSetRequiresApproveCapability,
46    #[error("Emergency freeze requires recovery:initiate capability or emergency flag")]
47    EmergencyFreezeRequiresInitiateCapability,
48}
49
50// =============================================================================
51// Service Configuration
52// =============================================================================
53
54/// Configuration for the authentication service
55#[derive(Debug, Clone)]
56pub struct AuthServiceConfig {
57    /// Default challenge expiration time in milliseconds
58    pub challenge_expiration_ms: u64,
59
60    /// Maximum session duration in seconds
61    pub max_session_duration_secs: u64,
62
63    /// Default guardian approval expiration in milliseconds
64    pub guardian_approval_expiration_ms: u64,
65
66    /// Whether to require explicit capability for recovery operations
67    pub require_recovery_capability: bool,
68}
69
70impl Default for AuthServiceConfig {
71    fn default() -> Self {
72        Self {
73            challenge_expiration_ms: 5 * 60 * 1000,  // 5 minutes
74            max_session_duration_secs: 24 * 60 * 60, // 24 hours
75            guardian_approval_expiration_ms: 7 * 24 * 60 * 60 * 1000, // 7 days
76            require_recovery_capability: true,
77        }
78    }
79}
80
81#[derive(Debug, Clone)]
82struct AuthPolicy {
83    #[allow(dead_code)] // Reserved for future policy enforcement
84    context_id: ContextId,
85    max_session_duration_secs: u64,
86    require_recovery_capability: bool,
87}
88
89impl AuthPolicy {
90    fn for_snapshot(config: &AuthServiceConfig, snapshot: &GuardSnapshot) -> Self {
91        Self {
92            context_id: derive_auth_context_id(snapshot),
93            max_session_duration_secs: config.max_session_duration_secs,
94            require_recovery_capability: config.require_recovery_capability,
95        }
96    }
97}
98
99fn derive_auth_context_id(snapshot: &GuardSnapshot) -> ContextId {
100    snapshot
101        .context_id
102        .unwrap_or_else(|| ContextId::new_from_entropy(hash(&snapshot.authority_id.to_bytes())))
103}
104
105// =============================================================================
106// Authentication Service
107// =============================================================================
108
109/// Main authentication service
110///
111/// Provides pure, synchronous guard evaluation for authentication operations.
112/// All effect execution is deferred to the caller via `EffectCommand`.
113#[derive(Debug, Clone)]
114pub struct AuthService {
115    config: AuthServiceConfig,
116}
117
118impl AuthService {
119    /// Create a new authentication service with default configuration
120    pub fn new() -> Self {
121        Self {
122            config: AuthServiceConfig::default(),
123        }
124    }
125
126    /// Create a new authentication service with custom configuration
127    pub fn with_config(config: AuthServiceConfig) -> Self {
128        Self { config }
129    }
130
131    // =========================================================================
132    // Challenge Operations
133    // =========================================================================
134
135    /// Request an authentication challenge
136    ///
137    /// Returns a `GuardOutcome` containing effect commands to generate
138    /// the challenge if allowed.
139    pub fn request_challenge(&self, snapshot: &GuardSnapshot, scope: SessionScope) -> GuardOutcome {
140        // Check capability
141        if let Some(outcome) =
142            check_capability(snapshot, &AuthenticationCapability::Request.as_name())
143        {
144            return outcome;
145        }
146
147        // Check budget
148        if let Some(outcome) = check_flow_budget(snapshot, costs::CHALLENGE_REQUEST_COST) {
149            return outcome;
150        }
151
152        let session_id = generate_session_id(snapshot);
153        let expires_at_ms = snapshot.now_ms + self.config.challenge_expiration_ms;
154
155        let context_id = derive_auth_context_id(snapshot);
156        let fact = AuthFact::ChallengeGenerated {
157            context_id,
158            session_id: session_id.clone(),
159            authority_id: snapshot.authority_id,
160            device_id: snapshot.device_id,
161            scope,
162            expires_at_ms,
163            created_at_ms: snapshot.now_ms,
164        };
165        let fact_data = fact.to_bytes();
166
167        GuardOutcome::allowed(vec![
168            EffectCommand::ChargeFlowBudget {
169                cost: costs::CHALLENGE_REQUEST_COST,
170            },
171            EffectCommand::GenerateChallenge {
172                session_id,
173                expires_at_ms,
174            },
175            EffectCommand::JournalAppend {
176                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
177                fact_data,
178            },
179        ])
180    }
181
182    // =========================================================================
183    // Proof Submission
184    // =========================================================================
185
186    /// Submit an identity proof for verification
187    ///
188    /// Returns a `GuardOutcome` containing effect commands to process
189    /// the proof if allowed.
190    pub fn submit_proof(
191        &self,
192        snapshot: &GuardSnapshot,
193        session_id: String,
194        proof_hash: [u8; 32],
195    ) -> GuardOutcome {
196        // Check capability
197        if let Some(outcome) =
198            check_capability(snapshot, &AuthenticationCapability::SubmitProof.as_name())
199        {
200            return outcome;
201        }
202
203        // Check budget
204        if let Some(outcome) = check_flow_budget(snapshot, costs::PROOF_SUBMISSION_COST) {
205            return outcome;
206        }
207
208        let context_id = derive_auth_context_id(snapshot);
209        let fact = AuthFact::ProofSubmitted {
210            context_id,
211            session_id: session_id.clone(),
212            authority_id: snapshot.authority_id,
213            proof_hash,
214            submitted_at_ms: snapshot.now_ms,
215        };
216        let fact_data = fact.to_bytes();
217
218        GuardOutcome::allowed(vec![
219            EffectCommand::ChargeFlowBudget {
220                cost: costs::PROOF_SUBMISSION_COST,
221            },
222            EffectCommand::JournalAppend {
223                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
224                fact_data,
225            },
226            EffectCommand::RecordReceipt {
227                operation: format!("proof_submission:{session_id}"),
228                peer: None,
229                timestamp_ms: snapshot.now_ms,
230            },
231        ])
232    }
233
234    // =========================================================================
235    // Session Creation
236    // =========================================================================
237
238    /// Create a session ticket after successful authentication
239    ///
240    /// Returns a `GuardOutcome` containing effect commands to issue
241    /// the session ticket if allowed.
242    pub fn create_session(
243        &self,
244        snapshot: &GuardSnapshot,
245        scope: SessionScope,
246        duration_seconds: u64,
247    ) -> GuardOutcome {
248        let policy = AuthPolicy::for_snapshot(&self.config, snapshot);
249        // Check capability
250        if let Some(outcome) =
251            check_capability(snapshot, &AuthenticationCapability::CreateSession.as_name())
252        {
253            return outcome;
254        }
255
256        // Check budget
257        if let Some(outcome) = check_flow_budget(snapshot, costs::SESSION_CREATION_COST) {
258            return outcome;
259        }
260
261        // Check duration
262        if duration_seconds > policy.max_session_duration_secs {
263            return GuardOutcome::denied(aura_guards::types::GuardViolation::other(
264                AuthGuardError::SessionDurationTooLong {
265                    requested: duration_seconds,
266                    max: policy.max_session_duration_secs,
267                }
268                .to_string(),
269            ));
270        }
271
272        let session_id = generate_session_id(snapshot);
273        let expires_at_ms = snapshot.now_ms + (duration_seconds * 1000);
274
275        let context_id = derive_auth_context_id(snapshot);
276        let fact = AuthFact::SessionIssued {
277            context_id,
278            session_id: session_id.clone(),
279            authority_id: snapshot.authority_id,
280            device_id: snapshot.device_id,
281            scope: scope.clone(),
282            issued_at_ms: snapshot.now_ms,
283            expires_at_ms,
284        };
285        let fact_data = fact.to_bytes();
286
287        GuardOutcome::allowed(vec![
288            EffectCommand::ChargeFlowBudget {
289                cost: costs::SESSION_CREATION_COST,
290            },
291            EffectCommand::IssueSessionTicket {
292                session_id,
293                scope,
294                expires_at_ms,
295            },
296            EffectCommand::JournalAppend {
297                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
298                fact_data,
299            },
300        ])
301    }
302
303    // =========================================================================
304    // Guardian Approval
305    // =========================================================================
306
307    /// Request guardian approval for a recovery operation
308    ///
309    /// Returns a `GuardOutcome` containing effect commands to initiate
310    /// the guardian approval process if allowed.
311    pub fn request_guardian_approval(
312        &self,
313        snapshot: &GuardSnapshot,
314        account_id: AuthorityId,
315        context: RecoveryContext,
316        required_guardians: u32,
317    ) -> GuardOutcome {
318        let policy = AuthPolicy::for_snapshot(&self.config, snapshot);
319        // Check capability
320        if let Some(outcome) =
321            check_capability(snapshot, &GuardianAuthCapability::RequestApproval.as_name())
322        {
323            return outcome;
324        }
325
326        // Check budget
327        if let Some(outcome) = check_flow_budget(snapshot, costs::GUARDIAN_APPROVAL_REQUEST_COST) {
328            return outcome;
329        }
330
331        // Check recovery operation type constraints
332        if policy.require_recovery_capability {
333            match context.operation_type {
334                RecoveryOperationType::GuardianSetModification => {
335                    if !snapshot.has_capability(&RecoveryAuthorizationCapability::Approve.as_name())
336                    {
337                        return GuardOutcome::denied(aura_guards::types::GuardViolation::other(
338                            AuthGuardError::GuardianSetRequiresApproveCapability.to_string(),
339                        ));
340                    }
341                }
342                RecoveryOperationType::EmergencyFreeze if !context.is_emergency => {
343                    if !snapshot
344                        .has_capability(&RecoveryAuthorizationCapability::Initiate.as_name())
345                    {
346                        return GuardOutcome::denied(aura_guards::types::GuardViolation::other(
347                            AuthGuardError::EmergencyFreezeRequiresInitiateCapability.to_string(),
348                        ));
349                    }
350                }
351                _ => {}
352            }
353        }
354
355        let request_id = generate_request_id(snapshot);
356        let expires_at_ms = snapshot.now_ms + self.config.guardian_approval_expiration_ms;
357
358        let context_id = derive_auth_context_id(snapshot);
359        let fact = AuthFact::GuardianApprovalRequested {
360            context_id,
361            request_id: request_id.clone(),
362            account_id,
363            requester_id: snapshot.authority_id,
364            operation_type: context.operation_type.clone(),
365            required_guardians,
366            is_emergency: context.is_emergency,
367            justification: context.justification,
368            requested_at_ms: snapshot.now_ms,
369            expires_at_ms,
370        };
371        let fact_data = fact.to_bytes();
372
373        GuardOutcome::allowed(vec![
374            EffectCommand::ChargeFlowBudget {
375                cost: costs::GUARDIAN_APPROVAL_REQUEST_COST,
376            },
377            EffectCommand::JournalAppend {
378                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
379                fact_data,
380            },
381            EffectCommand::AggregateGuardianApprovals {
382                request_id: request_id.clone(),
383                threshold: required_guardians,
384            },
385            EffectCommand::NotifyPeer {
386                peer: account_id,
387                event_type: "guardian_approval_request".to_string(),
388                event_data: request_id.into_bytes(),
389            },
390        ])
391    }
392
393    /// Submit a guardian approval decision
394    ///
395    /// Returns a `GuardOutcome` containing effect commands to record
396    /// the decision if allowed.
397    pub fn submit_guardian_decision(
398        &self,
399        snapshot: &GuardSnapshot,
400        request_id: String,
401        approved: bool,
402        justification: String,
403        signature: Vec<u8>,
404    ) -> GuardOutcome {
405        // Check capability
406        if let Some(outcome) = check_capability(snapshot, &GuardianAuthCapability::Verify.as_name())
407        {
408            return outcome;
409        }
410
411        // Check budget
412        if let Some(outcome) = check_flow_budget(snapshot, costs::GUARDIAN_APPROVAL_DECISION_COST) {
413            return outcome;
414        }
415
416        let context_id = derive_auth_context_id(snapshot);
417        let fact = if approved {
418            AuthFact::GuardianApproved {
419                context_id,
420                request_id: request_id.clone(),
421                guardian_id: snapshot.authority_id,
422                signature,
423                justification,
424                approved_at_ms: snapshot.now_ms,
425            }
426        } else {
427            AuthFact::GuardianDenied {
428                context_id,
429                request_id: request_id.clone(),
430                guardian_id: snapshot.authority_id,
431                reason: justification,
432                denied_at_ms: snapshot.now_ms,
433            }
434        };
435        let fact_data = fact.to_bytes();
436
437        GuardOutcome::allowed(vec![
438            EffectCommand::ChargeFlowBudget {
439                cost: costs::GUARDIAN_APPROVAL_DECISION_COST,
440            },
441            EffectCommand::JournalAppend {
442                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
443                fact_data,
444            },
445            EffectCommand::RecordReceipt {
446                operation: format!("guardian_decision:{request_id}:{approved}"),
447                peer: Some(snapshot.authority_id),
448                timestamp_ms: snapshot.now_ms,
449            },
450        ])
451    }
452
453    // =========================================================================
454    // Session Revocation
455    // =========================================================================
456
457    /// Revoke an active session
458    ///
459    /// Returns a `GuardOutcome` containing effect commands to revoke
460    /// the session if allowed.
461    pub fn revoke_session(
462        &self,
463        snapshot: &GuardSnapshot,
464        session_id: String,
465        reason: String,
466    ) -> GuardOutcome {
467        // Session revocation uses the create_session capability
468        if let Some(outcome) =
469            check_capability(snapshot, &AuthenticationCapability::CreateSession.as_name())
470        {
471            return outcome;
472        }
473
474        let context_id = derive_auth_context_id(snapshot);
475        let fact = AuthFact::SessionRevoked {
476            context_id,
477            session_id: session_id.clone(),
478            revoked_by: snapshot.authority_id,
479            reason,
480            revoked_at_ms: snapshot.now_ms,
481        };
482        let fact_data = fact.to_bytes();
483
484        GuardOutcome::allowed(vec![
485            EffectCommand::JournalAppend {
486                fact_type: crate::facts::AUTH_FACT_TYPE_ID.to_string(),
487                fact_data,
488            },
489            EffectCommand::RecordReceipt {
490                operation: format!("session_revocation:{session_id}"),
491                peer: None,
492                timestamp_ms: snapshot.now_ms,
493            },
494        ])
495    }
496}
497
498impl Default for AuthService {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504// =============================================================================
505// Helper Functions
506// =============================================================================
507
508/// Generate a short hex representation of an AuthorityId
509fn authority_hex_short(id: AuthorityId) -> String {
510    let bytes = id.to_bytes();
511    format!(
512        "{:02x}{:02x}{:02x}{:02x}",
513        bytes[0], bytes[1], bytes[2], bytes[3]
514    )
515}
516
517/// Generate a unique session ID based on the snapshot
518fn generate_session_id(snapshot: &GuardSnapshot) -> String {
519    format!(
520        "session_{}_{}_{}",
521        authority_hex_short(snapshot.authority_id),
522        snapshot.epoch,
523        snapshot.now_ms
524    )
525}
526
527/// Generate a unique request ID for guardian approval
528fn generate_request_id(snapshot: &GuardSnapshot) -> String {
529    format!(
530        "guardian_req_{}_{}_{}",
531        authority_hex_short(snapshot.authority_id),
532        snapshot.epoch,
533        snapshot.now_ms
534    )
535}
536
537// =============================================================================
538// Result Types
539// =============================================================================
540
541/// Result of a challenge request operation
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ChallengeResult {
544    /// Session ID for the challenge
545    pub session_id: String,
546    /// Challenge bytes to sign
547    pub challenge: Vec<u8>,
548    /// Challenge expiration timestamp (ms)
549    pub expires_at_ms: u64,
550    /// Whether the operation succeeded
551    pub success: bool,
552    /// Error message if failed
553    pub error: Option<String>,
554}
555
556/// Result of a session creation operation
557#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct SessionResult {
559    /// Session ticket ID
560    pub session_id: String,
561    /// Session expiration timestamp (ms)
562    pub expires_at_ms: u64,
563    /// Whether the operation succeeded
564    pub success: bool,
565    /// Error message if failed
566    pub error: Option<String>,
567}
568
569/// Result of a guardian approval request
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct GuardianApprovalResult {
572    /// Request ID
573    pub request_id: String,
574    /// Number of approvals received
575    pub approval_count: u32,
576    /// Required approvals
577    pub required_count: u32,
578    /// Whether threshold was met
579    pub threshold_met: bool,
580    /// Whether the operation succeeded
581    pub success: bool,
582    /// Error message if failed
583    pub error: Option<String>,
584}
585
586// =============================================================================
587// Tests
588// =============================================================================
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use aura_core::FlowCost;
594
595    fn test_authority() -> AuthorityId {
596        AuthorityId::new_from_entropy([1u8; 32])
597    }
598
599    fn test_snapshot() -> GuardSnapshot {
600        GuardSnapshot::new(
601            test_authority(),
602            None,
603            None,
604            FlowCost::new(100),
605            vec![
606                AuthenticationCapability::Request.as_name(),
607                AuthenticationCapability::SubmitProof.as_name(),
608                AuthenticationCapability::CreateSession.as_name(),
609                GuardianAuthCapability::RequestApproval.as_name(),
610                GuardianAuthCapability::Verify.as_name(),
611            ],
612            1,
613            1000,
614        )
615    }
616
617    #[test]
618    fn test_service_creation() {
619        let service = AuthService::new();
620        assert_eq!(service.config.challenge_expiration_ms, 5 * 60 * 1000);
621    }
622
623    #[test]
624    fn test_request_challenge_allowed() {
625        let service = AuthService::new();
626        let snapshot = test_snapshot();
627        let scope = SessionScope::Protocol {
628            protocol_type: "test".to_string(),
629        };
630
631        let outcome = service.request_challenge(&snapshot, scope);
632        assert!(outcome.is_allowed());
633        assert!(!outcome.effects.is_empty());
634    }
635
636    #[test]
637    fn test_request_challenge_missing_capability() {
638        let service = AuthService::new();
639        let snapshot = GuardSnapshot::new(
640            test_authority(),
641            None,
642            None,
643            FlowCost::new(100),
644            vec![], // No capabilities
645            1,
646            1000,
647        );
648        let scope = SessionScope::Protocol {
649            protocol_type: "test".to_string(),
650        };
651
652        let outcome = service.request_challenge(&snapshot, scope);
653        assert!(outcome.is_denied());
654    }
655
656    #[test]
657    fn test_create_session_duration_exceeded() {
658        let service = AuthService::new();
659        let snapshot = test_snapshot();
660        let scope = SessionScope::Protocol {
661            protocol_type: "test".to_string(),
662        };
663
664        let outcome = service.create_session(&snapshot, scope, 100_000); // > 24 hours
665        assert!(outcome.is_denied());
666    }
667
668    #[test]
669    fn test_create_session_allowed() {
670        let service = AuthService::new();
671        let snapshot = test_snapshot();
672        let scope = SessionScope::Protocol {
673            protocol_type: "test".to_string(),
674        };
675
676        let outcome = service.create_session(&snapshot, scope, 3600); // 1 hour
677        assert!(outcome.is_allowed());
678    }
679
680    #[test]
681    fn test_submit_proof_allowed() {
682        let service = AuthService::new();
683        let snapshot = test_snapshot();
684
685        let outcome = service.submit_proof(&snapshot, "session_123".to_string(), [0u8; 32]);
686        assert!(outcome.is_allowed());
687    }
688
689    #[test]
690    fn test_guardian_approval_request() {
691        let service = AuthService::new();
692        let snapshot = test_snapshot();
693        let context = RecoveryContext::new(
694            RecoveryOperationType::DeviceKeyRecovery,
695            "Lost device",
696            1000,
697        );
698
699        let outcome = service.request_guardian_approval(&snapshot, test_authority(), context, 2);
700        assert!(outcome.is_allowed());
701    }
702
703    #[test]
704    fn test_guardian_decision_approved() {
705        let service = AuthService::new();
706        let snapshot = test_snapshot();
707
708        let outcome = service.submit_guardian_decision(
709            &snapshot,
710            "request_123".to_string(),
711            true,
712            "Approved recovery".to_string(),
713            vec![0u8; 64],
714        );
715        assert!(outcome.is_allowed());
716    }
717
718    #[test]
719    fn test_guardian_decision_denied() {
720        let service = AuthService::new();
721        let snapshot = test_snapshot();
722
723        let outcome = service.submit_guardian_decision(
724            &snapshot,
725            "request_123".to_string(),
726            false,
727            "Suspicious request".to_string(),
728            vec![],
729        );
730        assert!(outcome.is_allowed());
731    }
732
733    #[test]
734    fn test_revoke_session() {
735        let service = AuthService::new();
736        let snapshot = test_snapshot();
737
738        let outcome = service.revoke_session(
739            &snapshot,
740            "session_123".to_string(),
741            "User requested".to_string(),
742        );
743        assert!(outcome.is_allowed());
744    }
745}