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        // Atomically consume one use.  The store enforces
84        // `current_uses < max_uses` in a single atomic operation,
85        // preventing double-spend races between concurrent validators.
86        self.store
87            .increment_uses(&record.id, record.max_uses)
88            .await?;
89
90        // After a successful atomic increment the use count is
91        // `record.current_uses + 1` (the snapshot was taken before
92        // the increment).
93        let new_uses = record.current_uses + 1;
94        let remaining = record.max_uses - new_uses;
95
96        debug!(
97            id = %record.id,
98            entity_id = %record.entity_id,
99            uses = new_uses,
100            remaining,
101            "OTP validated and consumed"
102        );
103
104        Ok(ValidationResult {
105            entity_id: record.entity_id,
106            profile: record.profile,
107            label: record.label,
108            remaining_uses: remaining,
109        })
110    }
111
112    /// Revoke an OTP by its record ID.
113    pub async fn revoke(&self, id: &uuid::Uuid) -> OtpResult<()> {
114        self.store.revoke(id).await
115    }
116
117    /// Reference to the underlying store.
118    pub fn store(&self) -> &S {
119        &self.store
120    }
121}