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}