1use 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#[derive(Debug, Clone)]
56pub struct AuthServiceConfig {
57 pub challenge_expiration_ms: u64,
59
60 pub max_session_duration_secs: u64,
62
63 pub guardian_approval_expiration_ms: u64,
65
66 pub require_recovery_capability: bool,
68}
69
70impl Default for AuthServiceConfig {
71 fn default() -> Self {
72 Self {
73 challenge_expiration_ms: 5 * 60 * 1000, max_session_duration_secs: 24 * 60 * 60, guardian_approval_expiration_ms: 7 * 24 * 60 * 60 * 1000, require_recovery_capability: true,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
82struct AuthPolicy {
83 #[allow(dead_code)] 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#[derive(Debug, Clone)]
114pub struct AuthService {
115 config: AuthServiceConfig,
116}
117
118impl AuthService {
119 pub fn new() -> Self {
121 Self {
122 config: AuthServiceConfig::default(),
123 }
124 }
125
126 pub fn with_config(config: AuthServiceConfig) -> Self {
128 Self { config }
129 }
130
131 pub fn request_challenge(&self, snapshot: &GuardSnapshot, scope: SessionScope) -> GuardOutcome {
140 if let Some(outcome) =
142 check_capability(snapshot, &AuthenticationCapability::Request.as_name())
143 {
144 return outcome;
145 }
146
147 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 pub fn submit_proof(
191 &self,
192 snapshot: &GuardSnapshot,
193 session_id: String,
194 proof_hash: [u8; 32],
195 ) -> GuardOutcome {
196 if let Some(outcome) =
198 check_capability(snapshot, &AuthenticationCapability::SubmitProof.as_name())
199 {
200 return outcome;
201 }
202
203 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 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 if let Some(outcome) =
251 check_capability(snapshot, &AuthenticationCapability::CreateSession.as_name())
252 {
253 return outcome;
254 }
255
256 if let Some(outcome) = check_flow_budget(snapshot, costs::SESSION_CREATION_COST) {
258 return outcome;
259 }
260
261 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 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 if let Some(outcome) =
321 check_capability(snapshot, &GuardianAuthCapability::RequestApproval.as_name())
322 {
323 return outcome;
324 }
325
326 if let Some(outcome) = check_flow_budget(snapshot, costs::GUARDIAN_APPROVAL_REQUEST_COST) {
328 return outcome;
329 }
330
331 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 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 if let Some(outcome) = check_capability(snapshot, &GuardianAuthCapability::Verify.as_name())
407 {
408 return outcome;
409 }
410
411 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 pub fn revoke_session(
462 &self,
463 snapshot: &GuardSnapshot,
464 session_id: String,
465 reason: String,
466 ) -> GuardOutcome {
467 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
504fn 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
517fn 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
527fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ChallengeResult {
544 pub session_id: String,
546 pub challenge: Vec<u8>,
548 pub expires_at_ms: u64,
550 pub success: bool,
552 pub error: Option<String>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct SessionResult {
559 pub session_id: String,
561 pub expires_at_ms: u64,
563 pub success: bool,
565 pub error: Option<String>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct GuardianApprovalResult {
572 pub request_id: String,
574 pub approval_count: u32,
576 pub required_count: u32,
578 pub threshold_met: bool,
580 pub success: bool,
582 pub error: Option<String>,
584}
585
586#[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![], 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); 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); 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}