Skip to main content

aura_authentication/
guards.rs

1//! Authentication Guard Types
2//!
3//! Guard chain integration 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//! Guard evaluation is pure and synchronous over a prepared `GuardSnapshot`.
10//! The evaluation returns `EffectCommand` data that an async interpreter executes.
11//! No guard performs I/O directly.
12//!
13//! ```text
14//! ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
15//! │  GuardSnapshot  │ --> │  Guard Eval     │ --> │  GuardOutcome   │
16//! │  (prepared      │     │  (pure, sync)   │     │  (decision +    │
17//! │   async)        │     │                 │     │   effect cmds)  │
18//! └─────────────────┘     └─────────────────┘     └─────────────────┘
19//!                                                          │
20//!                                                          v
21//!                                                 ┌─────────────────┐
22//!                                                 │ Effect Executor │
23//!                                                 │ (async)         │
24//!                                                 └─────────────────┘
25//! ```
26
27use aura_core::types::identifiers::{AuthorityId, ContextId};
28use aura_core::DeviceId;
29use aura_core::FlowCost;
30use aura_guards::types;
31use aura_signature::session::SessionScope;
32use serde::{Deserialize, Serialize};
33
34use crate::capabilities::{
35    AuthenticationCapability, GuardianAuthCapability, RecoveryAuthorizationCapability,
36};
37
38// =============================================================================
39// Guard Cost Constants
40// =============================================================================
41
42/// Guard cost and capability constants for authentication operations
43pub mod costs {
44    use aura_core::FlowCost;
45
46    // -------------------------------------------------------------------------
47    // Flow Costs
48    // -------------------------------------------------------------------------
49
50    /// Flow cost for requesting an authentication challenge
51    pub const CHALLENGE_REQUEST_COST: FlowCost = FlowCost::new(1);
52
53    /// Flow cost for submitting an authentication proof
54    pub const PROOF_SUBMISSION_COST: FlowCost = FlowCost::new(2);
55
56    /// Flow cost for verifying an authentication proof
57    pub const PROOF_VERIFICATION_COST: FlowCost = FlowCost::new(2);
58
59    /// Flow cost for creating a session ticket
60    pub const SESSION_CREATION_COST: FlowCost = FlowCost::new(2);
61
62    /// Flow cost for guardian approval request
63    pub const GUARDIAN_APPROVAL_REQUEST_COST: FlowCost = FlowCost::new(3);
64
65    /// Flow cost for submitting guardian approval decision
66    pub const GUARDIAN_APPROVAL_DECISION_COST: FlowCost = FlowCost::new(2);
67}
68
69// =============================================================================
70// Guard Snapshot
71// =============================================================================
72
73/// Snapshot of guard-relevant state for evaluation.
74///
75/// This is prepared asynchronously before guard evaluation,
76/// allowing the evaluation itself to be pure and synchronous.
77#[derive(Debug, Clone)]
78pub struct GuardSnapshot {
79    /// Authority performing the operation
80    pub authority_id: AuthorityId,
81
82    /// Context for the operation (if applicable)
83    pub context_id: Option<ContextId>,
84
85    /// Device performing the operation (for device-level auth)
86    pub device_id: Option<DeviceId>,
87
88    /// Current flow budget remaining
89    pub flow_budget_remaining: FlowCost,
90
91    /// Capabilities held by the authority
92    pub capabilities: Vec<types::CapabilityId>,
93
94    /// Current epoch
95    pub epoch: u64,
96
97    /// Current timestamp in milliseconds
98    pub now_ms: u64,
99
100    /// Whether this is an emergency operation (bypasses some checks)
101    pub is_emergency: bool,
102}
103
104impl GuardSnapshot {
105    /// Create a new guard snapshot
106    pub fn new(
107        authority_id: AuthorityId,
108        context_id: Option<ContextId>,
109        device_id: Option<DeviceId>,
110        flow_budget_remaining: FlowCost,
111        capabilities: Vec<types::CapabilityId>,
112        epoch: u64,
113        now_ms: u64,
114    ) -> Self {
115        Self {
116            authority_id,
117            context_id,
118            device_id,
119            flow_budget_remaining,
120            capabilities,
121            epoch,
122            now_ms,
123            is_emergency: false,
124        }
125    }
126
127    /// Create a snapshot with emergency flag
128    pub fn with_emergency(mut self, is_emergency: bool) -> Self {
129        self.is_emergency = is_emergency;
130        self
131    }
132
133    /// Check if snapshot has a specific capability
134    pub fn has_capability(&self, cap: &types::CapabilityId) -> bool {
135        self.capabilities.iter().any(|c| c == cap)
136    }
137
138    /// Check if snapshot has sufficient flow budget
139    pub fn has_budget(&self, cost: FlowCost) -> bool {
140        self.flow_budget_remaining >= cost
141    }
142}
143
144// =============================================================================
145// Guard Request
146// =============================================================================
147
148/// Request to be evaluated by guards
149#[derive(Debug, Clone)]
150pub enum GuardRequest {
151    /// Request for authentication challenge
152    ChallengeRequest {
153        /// Scope of requested authentication
154        scope: SessionScope,
155    },
156
157    /// Submit identity proof for verification
158    ProofSubmission {
159        /// Session ID from challenge
160        session_id: String,
161        /// Hash of the proof being submitted
162        proof_hash: [u8; 32],
163    },
164
165    /// Verify submitted proof and issue session
166    ProofVerification {
167        /// Session ID being verified
168        session_id: String,
169    },
170
171    /// Create session ticket after successful auth
172    SessionCreation {
173        /// Session scope requested
174        scope: SessionScope,
175        /// Duration in seconds
176        duration_seconds: u64,
177    },
178
179    /// Request guardian approval for recovery
180    GuardianApprovalRequest {
181        /// Account being recovered
182        account_id: AuthorityId,
183        /// Type of recovery operation
184        operation_type: RecoveryOperationType,
185        /// Required number of guardian approvals
186        required_guardians: u32,
187    },
188
189    /// Submit guardian approval decision
190    GuardianApprovalDecision {
191        /// Request ID being responded to
192        request_id: String,
193        /// Whether approving or denying
194        approved: bool,
195    },
196}
197
198/// Types of recovery operations requiring guardian approval
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub enum RecoveryOperationType {
201    /// Device key recovery
202    DeviceKeyRecovery,
203    /// Account access recovery
204    AccountAccessRecovery,
205    /// Guardian set modification
206    GuardianSetModification,
207    /// Emergency account freeze
208    EmergencyFreeze,
209    /// Account unfreezing
210    AccountUnfreeze,
211}
212
213/// Recovery context for guardian authentication
214///
215/// Contains all the information needed to evaluate a recovery request
216/// through the guard chain.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct RecoveryContext {
219    /// Recovery operation type
220    pub operation_type: RecoveryOperationType,
221    /// Recovery justification
222    pub justification: String,
223    /// Emergency status (bypasses some checks)
224    pub is_emergency: bool,
225    /// Recovery timestamp (ms)
226    pub timestamp: u64,
227}
228
229impl RecoveryContext {
230    /// Create a new recovery context
231    pub fn new(
232        operation_type: RecoveryOperationType,
233        justification: impl Into<String>,
234        timestamp: u64,
235    ) -> Self {
236        Self {
237            operation_type,
238            justification: justification.into(),
239            is_emergency: false,
240            timestamp,
241        }
242    }
243
244    /// Create an emergency recovery context
245    pub fn emergency(
246        operation_type: RecoveryOperationType,
247        justification: impl Into<String>,
248        timestamp: u64,
249    ) -> Self {
250        Self {
251            operation_type,
252            justification: justification.into(),
253            is_emergency: true,
254            timestamp,
255        }
256    }
257}
258
259/// Decision type shared across Layer 5 feature crates.
260pub type GuardDecision = types::GuardDecision;
261
262// =============================================================================
263// Effect Command
264// =============================================================================
265
266/// Effect command to be executed after guard approval.
267///
268/// These commands are produced by pure guard evaluation and
269/// executed asynchronously by the effect system.
270#[derive(Debug, Clone)]
271pub enum EffectCommand {
272    /// Generate a cryptographic challenge
273    GenerateChallenge {
274        /// Session ID for the challenge
275        session_id: String,
276        /// Challenge expiration timestamp (ms)
277        expires_at_ms: u64,
278    },
279
280    /// Sign a message with authority key
281    SignMessage {
282        /// Message to sign
283        message: Vec<u8>,
284        /// Context for the signature
285        context: String,
286    },
287
288    /// Verify a signature
289    VerifySignature {
290        /// Message that was signed
291        message: Vec<u8>,
292        /// Signature to verify
293        signature: Vec<u8>,
294        /// Public key for verification
295        public_key: Vec<u8>,
296    },
297
298    /// Issue a session ticket
299    IssueSessionTicket {
300        /// Session ID
301        session_id: String,
302        /// Session scope
303        scope: SessionScope,
304        /// Expiration timestamp (ms)
305        expires_at_ms: u64,
306    },
307
308    /// Charge flow budget
309    ChargeFlowBudget {
310        /// Cost to charge
311        cost: FlowCost,
312    },
313
314    /// Append fact to journal
315    JournalAppend {
316        /// Fact type identifier
317        fact_type: String,
318        /// Serialized fact data
319        fact_data: Vec<u8>,
320    },
321
322    /// Notify peer about authentication event
323    NotifyPeer {
324        /// Peer to notify
325        peer: AuthorityId,
326        /// Event type
327        event_type: String,
328        /// Event data
329        event_data: Vec<u8>,
330    },
331
332    /// Record receipt for audit
333    RecordReceipt {
334        /// Operation name
335        operation: String,
336        /// Peer involved (if any)
337        peer: Option<AuthorityId>,
338        /// Timestamp
339        timestamp_ms: u64,
340    },
341
342    /// Send guardian challenge
343    SendGuardianChallenge {
344        /// Guardian to challenge
345        guardian_id: AuthorityId,
346        /// Request ID
347        request_id: String,
348        /// Challenge bytes
349        challenge: Vec<u8>,
350        /// Expiration timestamp (ms)
351        expires_at_ms: u64,
352    },
353
354    /// Aggregate guardian approvals
355    AggregateGuardianApprovals {
356        /// Request ID
357        request_id: String,
358        /// Required threshold
359        threshold: u32,
360    },
361}
362
363/// Outcome type shared across Layer 5 feature crates.
364pub type GuardOutcome = types::GuardOutcome<EffectCommand>;
365
366/// Typed guard rejection for consistent error reporting.
367#[derive(Debug, Clone, Copy)]
368pub struct GuardReject {
369    pub code: &'static str,
370    pub category: &'static str,
371    pub message: &'static str,
372}
373
374impl std::fmt::Display for GuardReject {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        write!(f, "[{}:{}] {}", self.category, self.code, self.message)
377    }
378}
379
380fn deny(reject: GuardReject) -> GuardOutcome {
381    GuardOutcome::denied(types::GuardViolation::other(reject.to_string()))
382}
383
384// =============================================================================
385// Guard Helpers
386// =============================================================================
387
388impl types::CapabilitySnapshot for GuardSnapshot {
389    fn has_capability(&self, cap: &types::CapabilityId) -> bool {
390        GuardSnapshot::has_capability(self, cap)
391    }
392}
393
394impl types::FlowBudgetSnapshot for GuardSnapshot {
395    fn flow_budget_remaining(&self) -> FlowCost {
396        self.flow_budget_remaining
397    }
398}
399
400/// Check capability and return denied outcome if missing
401pub fn check_capability(
402    snapshot: &GuardSnapshot,
403    required_cap: &types::CapabilityId,
404) -> Option<GuardOutcome> {
405    if snapshot.has_capability(required_cap) {
406        None
407    } else {
408        Some(deny(GuardReject {
409            code: "capability-missing",
410            category: "auth",
411            message: "Required capability missing",
412        }))
413    }
414}
415
416/// Check flow budget and return denied outcome if insufficient
417pub fn check_flow_budget(
418    snapshot: &GuardSnapshot,
419    required_cost: FlowCost,
420) -> Option<GuardOutcome> {
421    if snapshot.flow_budget_remaining >= required_cost {
422        None
423    } else {
424        Some(deny(GuardReject {
425            code: "flow-budget-insufficient",
426            category: "auth",
427            message: "Flow budget insufficient",
428        }))
429    }
430}
431
432/// Check if challenge has expired
433pub fn check_challenge_expiry(
434    snapshot: &GuardSnapshot,
435    expires_at_ms: u64,
436) -> Option<GuardOutcome> {
437    if snapshot.now_ms > expires_at_ms {
438        Some(deny(GuardReject {
439            code: "challenge-expired",
440            category: "auth",
441            message: "Challenge has expired",
442        }))
443    } else {
444        None
445    }
446}
447
448/// Check if session duration is within acceptable bounds
449pub fn check_session_duration(duration_seconds: u64) -> Option<GuardOutcome> {
450    const MAX_SESSION_DURATION_SECS: u64 = 86400; // 24 hours
451
452    if duration_seconds > MAX_SESSION_DURATION_SECS {
453        Some(deny(GuardReject {
454            code: "session-duration-too-long",
455            category: "auth",
456            message: "Session duration exceeds maximum",
457        }))
458    } else {
459        None
460    }
461}
462
463/// Check if recovery operation is allowed for the given operation type
464pub fn check_recovery_operation(
465    snapshot: &GuardSnapshot,
466    operation_type: &RecoveryOperationType,
467) -> Option<GuardOutcome> {
468    // Emergency operations bypass normal checks but are logged
469    if snapshot.is_emergency {
470        return None;
471    }
472
473    match operation_type {
474        RecoveryOperationType::GuardianSetModification => {
475            // Guardian set modifications require explicit capability
476            if !snapshot.has_capability(&RecoveryAuthorizationCapability::Approve.as_name()) {
477                return Some(deny(GuardReject {
478                    code: "guardian-set-approval-required",
479                    category: "auth",
480                    message: "Guardian set modification requires recovery:approve capability",
481                }));
482            }
483        }
484        RecoveryOperationType::EmergencyFreeze => {
485            // Emergency freeze requires emergency flag or explicit capability
486            if !snapshot.has_capability(&RecoveryAuthorizationCapability::Initiate.as_name()) {
487                return Some(deny(GuardReject {
488                    code: "emergency-freeze-requires-capability",
489                    category: "auth",
490                    message: "Emergency freeze requires recovery:initiate capability",
491                }));
492            }
493        }
494        _ => {
495            // Other operations allowed with standard capabilities
496        }
497    }
498
499    None
500}
501
502// =============================================================================
503// Guard Evaluator
504// =============================================================================
505
506/// Evaluate a guard request against a snapshot
507pub fn evaluate_request(snapshot: &GuardSnapshot, request: &GuardRequest) -> GuardOutcome {
508    match request {
509        GuardRequest::ChallengeRequest { scope: _ } => {
510            // Check capability
511            if let Some(outcome) =
512                check_capability(snapshot, &AuthenticationCapability::Request.as_name())
513            {
514                return outcome;
515            }
516
517            // Check budget
518            if let Some(outcome) = check_flow_budget(snapshot, costs::CHALLENGE_REQUEST_COST) {
519                return outcome;
520            }
521
522            // Generate session ID and challenge
523            let session_id = format!("session_{}", snapshot.epoch);
524            let expires_at_ms = snapshot.now_ms + 300_000; // 5 minutes
525
526            GuardOutcome::allowed(vec![
527                EffectCommand::ChargeFlowBudget {
528                    cost: costs::CHALLENGE_REQUEST_COST,
529                },
530                EffectCommand::GenerateChallenge {
531                    session_id,
532                    expires_at_ms,
533                },
534            ])
535        }
536
537        GuardRequest::ProofSubmission {
538            session_id,
539            proof_hash,
540        } => {
541            // Check capability
542            if let Some(outcome) =
543                check_capability(snapshot, &AuthenticationCapability::SubmitProof.as_name())
544            {
545                return outcome;
546            }
547
548            // Check budget
549            if let Some(outcome) = check_flow_budget(snapshot, costs::PROOF_SUBMISSION_COST) {
550                return outcome;
551            }
552
553            GuardOutcome::allowed(vec![
554                EffectCommand::ChargeFlowBudget {
555                    cost: costs::PROOF_SUBMISSION_COST,
556                },
557                EffectCommand::JournalAppend {
558                    fact_type: "auth_proof_submitted".to_string(),
559                    fact_data: proof_hash.to_vec(),
560                },
561                EffectCommand::RecordReceipt {
562                    operation: format!("proof_submission:{session_id}"),
563                    peer: None,
564                    timestamp_ms: snapshot.now_ms,
565                },
566            ])
567        }
568
569        GuardRequest::ProofVerification { session_id } => {
570            // Check capability
571            if let Some(outcome) =
572                check_capability(snapshot, &AuthenticationCapability::Verify.as_name())
573            {
574                return outcome;
575            }
576
577            // Check budget
578            if let Some(outcome) = check_flow_budget(snapshot, costs::PROOF_VERIFICATION_COST) {
579                return outcome;
580            }
581
582            GuardOutcome::allowed(vec![
583                EffectCommand::ChargeFlowBudget {
584                    cost: costs::PROOF_VERIFICATION_COST,
585                },
586                EffectCommand::JournalAppend {
587                    fact_type: "auth_verification_started".to_string(),
588                    fact_data: session_id.as_bytes().to_vec(),
589                },
590            ])
591        }
592
593        GuardRequest::SessionCreation {
594            scope,
595            duration_seconds,
596        } => {
597            // Check capability
598            if let Some(outcome) =
599                check_capability(snapshot, &AuthenticationCapability::CreateSession.as_name())
600            {
601                return outcome;
602            }
603
604            // Check budget
605            if let Some(outcome) = check_flow_budget(snapshot, costs::SESSION_CREATION_COST) {
606                return outcome;
607            }
608
609            // Check duration
610            if let Some(outcome) = check_session_duration(*duration_seconds) {
611                return outcome;
612            }
613
614            let session_id = format!("session_{}", snapshot.epoch);
615            let expires_at_ms = snapshot.now_ms + (duration_seconds * 1000);
616
617            GuardOutcome::allowed(vec![
618                EffectCommand::ChargeFlowBudget {
619                    cost: costs::SESSION_CREATION_COST,
620                },
621                EffectCommand::IssueSessionTicket {
622                    session_id,
623                    scope: scope.clone(),
624                    expires_at_ms,
625                },
626            ])
627        }
628
629        GuardRequest::GuardianApprovalRequest {
630            account_id,
631            operation_type,
632            required_guardians,
633        } => {
634            // Check capability
635            if let Some(outcome) =
636                check_capability(snapshot, &GuardianAuthCapability::RequestApproval.as_name())
637            {
638                return outcome;
639            }
640
641            // Check budget
642            if let Some(outcome) =
643                check_flow_budget(snapshot, costs::GUARDIAN_APPROVAL_REQUEST_COST)
644            {
645                return outcome;
646            }
647
648            // Check recovery operation type
649            if let Some(outcome) = check_recovery_operation(snapshot, operation_type) {
650                return outcome;
651            }
652
653            let request_id = format!("guardian_req_{}", snapshot.epoch);
654
655            GuardOutcome::allowed(vec![
656                EffectCommand::ChargeFlowBudget {
657                    cost: costs::GUARDIAN_APPROVAL_REQUEST_COST,
658                },
659                EffectCommand::JournalAppend {
660                    fact_type: "guardian_approval_requested".to_string(),
661                    fact_data: request_id.as_bytes().to_vec(),
662                },
663                EffectCommand::AggregateGuardianApprovals {
664                    request_id: request_id.clone(),
665                    threshold: *required_guardians,
666                },
667                EffectCommand::NotifyPeer {
668                    peer: *account_id,
669                    event_type: "guardian_approval_request".to_string(),
670                    event_data: request_id.into_bytes(),
671                },
672            ])
673        }
674
675        GuardRequest::GuardianApprovalDecision {
676            request_id,
677            approved,
678        } => {
679            // Check capability
680            if let Some(outcome) =
681                check_capability(snapshot, &GuardianAuthCapability::Verify.as_name())
682            {
683                return outcome;
684            }
685
686            // Check budget
687            if let Some(outcome) =
688                check_flow_budget(snapshot, costs::GUARDIAN_APPROVAL_DECISION_COST)
689            {
690                return outcome;
691            }
692
693            let fact_type = if *approved {
694                "guardian_approved"
695            } else {
696                "guardian_denied"
697            };
698
699            GuardOutcome::allowed(vec![
700                EffectCommand::ChargeFlowBudget {
701                    cost: costs::GUARDIAN_APPROVAL_DECISION_COST,
702                },
703                EffectCommand::JournalAppend {
704                    fact_type: fact_type.to_string(),
705                    fact_data: request_id.as_bytes().to_vec(),
706                },
707                EffectCommand::RecordReceipt {
708                    operation: format!("guardian_decision:{request_id}:{approved}"),
709                    peer: Some(snapshot.authority_id),
710                    timestamp_ms: snapshot.now_ms,
711                },
712            ])
713        }
714    }
715}
716
717// =============================================================================
718// Tests
719// =============================================================================
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    fn test_authority() -> AuthorityId {
726        AuthorityId::new_from_entropy([1u8; 32])
727    }
728
729    fn test_snapshot() -> GuardSnapshot {
730        GuardSnapshot::new(
731            test_authority(),
732            None,
733            None,
734            FlowCost::new(100),
735            vec![
736                AuthenticationCapability::Request.as_name(),
737                AuthenticationCapability::SubmitProof.as_name(),
738                AuthenticationCapability::Verify.as_name(),
739                AuthenticationCapability::CreateSession.as_name(),
740            ],
741            1,
742            1000,
743        )
744    }
745
746    #[test]
747    fn test_guard_snapshot_has_capability() {
748        let snapshot = test_snapshot();
749        assert!(snapshot.has_capability(&AuthenticationCapability::Request.as_name()));
750        assert!(snapshot.has_capability(&AuthenticationCapability::SubmitProof.as_name()));
751        assert!(!snapshot.has_capability(&GuardianAuthCapability::RequestApproval.as_name()));
752    }
753
754    #[test]
755    fn test_guard_snapshot_has_budget() {
756        let snapshot = test_snapshot();
757        assert!(snapshot.has_budget(FlowCost::new(50)));
758        assert!(snapshot.has_budget(FlowCost::new(100)));
759        assert!(!snapshot.has_budget(FlowCost::new(101)));
760    }
761
762    #[test]
763    fn test_guard_decision_allow() {
764        let decision = GuardDecision::allow();
765        assert!(decision.is_allowed());
766        assert!(!decision.is_denied());
767        assert!(decision.denial_reason().is_none());
768    }
769
770    #[test]
771    fn test_guard_decision_deny() {
772        let decision = GuardDecision::deny(types::GuardViolation::other("test reason"));
773        assert!(!decision.is_allowed());
774        assert!(decision.is_denied());
775        assert!(matches!(
776            decision.denial_reason(),
777            Some(types::GuardViolation::Other(reason)) if reason == "test reason"
778        ));
779    }
780
781    #[test]
782    fn test_guard_outcome_allowed() {
783        let outcome = GuardOutcome::allowed(vec![EffectCommand::ChargeFlowBudget {
784            cost: FlowCost::new(10),
785        }]);
786        assert!(outcome.is_allowed());
787        assert_eq!(outcome.effects.len(), 1);
788    }
789
790    #[test]
791    fn test_guard_outcome_denied() {
792        let outcome = GuardOutcome::denied(types::GuardViolation::other("no budget"));
793        assert!(outcome.is_denied());
794        assert!(outcome.effects.is_empty());
795    }
796
797    #[test]
798    fn test_check_capability_success() {
799        let snapshot = test_snapshot();
800        let result = check_capability(&snapshot, &AuthenticationCapability::Request.as_name());
801        assert!(result.is_none());
802    }
803
804    #[test]
805    fn test_check_capability_failure() {
806        let snapshot = test_snapshot();
807        let result = check_capability(
808            &snapshot,
809            &GuardianAuthCapability::RequestApproval.as_name(),
810        );
811        assert!(result.is_some());
812        assert!(result.unwrap().is_denied());
813    }
814
815    #[test]
816    fn test_check_flow_budget_success() {
817        let snapshot = test_snapshot();
818        let result = check_flow_budget(&snapshot, FlowCost::new(50));
819        assert!(result.is_none());
820    }
821
822    #[test]
823    fn test_check_flow_budget_failure() {
824        let snapshot = test_snapshot();
825        let result = check_flow_budget(&snapshot, FlowCost::new(150));
826        assert!(result.is_some());
827        assert!(result.unwrap().is_denied());
828    }
829
830    /// Challenge request succeeds with required capability and budget.
831    #[test]
832    fn test_evaluate_challenge_request() {
833        let snapshot = test_snapshot();
834        let request = GuardRequest::ChallengeRequest {
835            scope: SessionScope::Protocol {
836                protocol_type: "test".to_string(),
837            },
838        };
839
840        let outcome = evaluate_request(&snapshot, &request);
841        assert!(outcome.is_allowed());
842        assert!(!outcome.effects.is_empty());
843    }
844
845    /// Session creation denied when requested duration exceeds maximum —
846    /// prevents unbounded session lifetimes.
847    #[test]
848    fn test_evaluate_session_creation_duration_exceeded() {
849        let snapshot = test_snapshot();
850        let request = GuardRequest::SessionCreation {
851            scope: SessionScope::Protocol {
852                protocol_type: "test".to_string(),
853            },
854            duration_seconds: 100_000, // > 24 hours
855        };
856
857        let outcome = evaluate_request(&snapshot, &request);
858        assert!(outcome.is_denied());
859    }
860
861    /// Expired challenges are rejected — prevents replay of stale challenges.
862    #[test]
863    fn test_check_challenge_expiry() {
864        let snapshot = test_snapshot();
865
866        // Not expired
867        let result = check_challenge_expiry(&snapshot, 2000);
868        assert!(result.is_none());
869
870        // Expired
871        let result = check_challenge_expiry(&snapshot, 500);
872        assert!(result.is_some());
873        assert!(result.unwrap().is_denied());
874    }
875
876    #[test]
877    fn test_guard_costs_defined() {
878        assert_eq!(costs::CHALLENGE_REQUEST_COST.value(), 1);
879        assert_eq!(costs::PROOF_SUBMISSION_COST.value(), 2);
880        assert_eq!(
881            AuthenticationCapability::Request.as_name().as_str(),
882            "auth:request"
883        );
884    }
885}