Skip to main content

kipuka_otp/
validate.rs

1//! OTP validation and consumption with timing-safe comparison.
2//!
3//! Implements RHELBU-3536 R8 (timing-safe comparison) and R9/R10
4//! (single-use / multi-use with expiration).
5
6use chrono::Utc;
7use tracing::{debug, warn};
8
9use crate::generate::OtpGenerator;
10use crate::store::OtpStore;
11use crate::{OtpError, OtpResult};
12
13/// Result of a successful OTP validation.
14#[derive(Debug, Clone)]
15pub struct ValidationResult {
16    /// Entity (host/user/service) authorized by this OTP.
17    pub entity_id: String,
18    /// Enrollment profile to apply for this entity.
19    pub profile: String,
20    /// Label from the OTP record.
21    pub label: String,
22    /// Remaining uses after this consumption (0 for single-use tokens).
23    pub remaining_uses: u32,
24}
25
26/// Validates and consumes OTP tokens.
27///
28/// Performs timing-safe hash comparison against the store to prevent
29/// timing side-channel attacks (RHELBU-3536 R8).
30pub struct OtpValidator<S: OtpStore> {
31    store: S,
32}
33
34impl<S: OtpStore> OtpValidator<S> {
35    /// Create a validator backed by the given store.
36    pub fn new(store: S) -> Self {
37        Self { store }
38    }
39
40    /// Validate a plaintext OTP token.
41    ///
42    /// Checks, in order:
43    /// 1. Token exists in the store (by SHA-256 hash lookup)
44    /// 2. Token is not revoked
45    /// 3. Token has not expired
46    /// 4. Token has not exceeded its max-use count
47    ///
48    /// On success, increments the usage counter and returns entity
49    /// metadata for authorization. Single-use tokens are consumed
50    /// (marked with `current_uses == max_uses`) on first successful
51    /// validation.
52    ///
53    /// # Timing Safety (RHELBU-3536 R8)
54    ///
55    /// The store lookup is by hash, not by iterating and comparing
56    /// plaintext values. The SHA-256 pre-image resistance ensures that
57    /// even if an attacker observes lookup timing, they cannot infer
58    /// the token value.
59    pub async fn validate(&self, plaintext_token: &str) -> OtpResult<ValidationResult> {
60        let token_hash = OtpGenerator::hash_token(plaintext_token);
61
62        let record = self
63            .store
64            .find_by_hash(&token_hash)
65            .await?
66            .ok_or(OtpError::NotFound)?;
67
68        // Check revocation.
69        if record.revoked {
70            warn!(id = %record.id, entity_id = %record.entity_id, "OTP is revoked");
71            return Err(OtpError::Revoked);
72        }
73
74        // Check expiration.
75        let now = Utc::now();
76        if now > record.expires_at {
77            debug!(id = %record.id, expired_at = %record.expires_at, "OTP has expired");
78            return Err(OtpError::Expired {
79                expired_at: record.expires_at.to_rfc3339(),
80            });
81        }
82
83        // Check usage limit.
84        if record.current_uses >= record.max_uses {
85            warn!(
86                id = %record.id,
87                current = record.current_uses,
88                max = record.max_uses,
89                "OTP usage limit exceeded"
90            );
91            return Err(OtpError::UsageLimitExceeded {
92                max_uses: record.max_uses,
93            });
94        }
95
96        // Consume: increment usage counter.
97        let new_uses = record.current_uses + 1;
98        self.store.increment_uses(&record.id, new_uses).await?;
99
100        let remaining = record.max_uses - new_uses;
101
102        debug!(
103            id = %record.id,
104            entity_id = %record.entity_id,
105            uses = new_uses,
106            remaining,
107            "OTP validated and consumed"
108        );
109
110        Ok(ValidationResult {
111            entity_id: record.entity_id,
112            profile: record.profile,
113            label: record.label,
114            remaining_uses: remaining,
115        })
116    }
117
118    /// Revoke an OTP by its record ID.
119    pub async fn revoke(&self, id: &uuid::Uuid) -> OtpResult<()> {
120        self.store.revoke(id).await
121    }
122
123    /// Reference to the underlying store.
124    pub fn store(&self) -> &S {
125        &self.store
126    }
127}