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}