Skip to main content

kipuka_otp/
store.rs

1//! Pluggable OTP storage backends.
2//!
3//! Implements RHELBU-3536 R11 (hash-only storage) and R12 (periodic cleanup).
4//! Tokens are stored as SHA-256 hashes; the plaintext is never persisted.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use chrono::{DateTime, Utc};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, info};
13use uuid::Uuid;
14
15use crate::{OtpError, OtpResult};
16
17/// Persistent OTP record (RHELBU-3536 R11).
18///
19/// The `token_hash` field stores the SHA-256 digest of the plaintext
20/// token. The plaintext is never written to any storage backend.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OtpRecord {
23    /// Unique record identifier.
24    pub id: Uuid,
25    /// SHA-256 hash of the plaintext token (32 bytes).
26    pub token_hash: Vec<u8>,
27    /// Entity (host, user, service) this OTP authorizes enrollment for.
28    pub entity_id: String,
29    /// Human-readable label.
30    pub label: String,
31    /// Enrollment profile to apply.
32    pub profile: String,
33    /// Creation timestamp.
34    pub created_at: DateTime<Utc>,
35    /// Expiration timestamp.
36    pub expires_at: DateTime<Utc>,
37    /// Maximum number of allowed uses.
38    pub max_uses: u32,
39    /// Current number of completed uses.
40    pub current_uses: u32,
41    /// Whether the OTP has been administratively revoked.
42    pub revoked: bool,
43}
44
45/// Trait for pluggable OTP storage backends.
46///
47/// Implementors must be `Send + Sync` for use in async contexts.
48pub trait OtpStore: Send + Sync {
49    /// Insert a new OTP record.
50    fn insert(&self, record: OtpRecord) -> impl std::future::Future<Output = OtpResult<()>> + Send;
51
52    /// Find a record by its token hash.
53    fn find_by_hash(
54        &self,
55        hash: &[u8],
56    ) -> impl std::future::Future<Output = OtpResult<Option<OtpRecord>>> + Send;
57
58    /// Atomically increment the usage counter for a record.
59    ///
60    /// The increment must only succeed when `current_uses < max_uses`,
61    /// preventing double-spend races where two concurrent validators
62    /// both read the same count and both increment.  Returns
63    /// `Err(OtpError::UsageLimitExceeded)` if the OTP was already
64    /// fully consumed by a concurrent request.
65    fn increment_uses(
66        &self,
67        id: &Uuid,
68        max_uses: u32,
69    ) -> impl std::future::Future<Output = OtpResult<()>> + Send;
70
71    /// Mark a record as revoked.
72    fn revoke(&self, id: &Uuid) -> impl std::future::Future<Output = OtpResult<()>> + Send;
73
74    /// Remove all expired records (RHELBU-3536 R12).
75    fn cleanup_expired(&self) -> impl std::future::Future<Output = OtpResult<u64>> + Send;
76}
77
78// ---------------------------------------------------------------------------
79// In-memory implementation (testing)
80// ---------------------------------------------------------------------------
81
82/// In-memory OTP store for unit testing.
83///
84/// Not suitable for production; all data is lost on process exit.
85#[derive(Clone)]
86pub struct InMemoryOtpStore {
87    records: Arc<RwLock<HashMap<Uuid, OtpRecord>>>,
88}
89
90impl InMemoryOtpStore {
91    /// Create an empty in-memory store.
92    pub fn new() -> Self {
93        Self {
94            records: Arc::new(RwLock::new(HashMap::new())),
95        }
96    }
97}
98
99impl Default for InMemoryOtpStore {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl OtpStore for InMemoryOtpStore {
106    async fn insert(&self, record: OtpRecord) -> OtpResult<()> {
107        debug!(id = %record.id, entity_id = %record.entity_id, "inserting OTP record");
108        self.records.write().insert(record.id, record);
109        Ok(())
110    }
111
112    async fn find_by_hash(&self, hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
113        let records = self.records.read();
114        Ok(records.values().find(|r| r.token_hash == hash).cloned())
115    }
116
117    async fn increment_uses(&self, id: &Uuid, max_uses: u32) -> OtpResult<()> {
118        let mut records = self.records.write();
119        match records.get_mut(id) {
120            Some(r) => {
121                if r.current_uses >= max_uses {
122                    return Err(OtpError::UsageLimitExceeded { max_uses });
123                }
124                r.current_uses += 1;
125                Ok(())
126            }
127            None => Err(OtpError::NotFound),
128        }
129    }
130
131    async fn revoke(&self, id: &Uuid) -> OtpResult<()> {
132        let mut records = self.records.write();
133        match records.get_mut(id) {
134            Some(r) => {
135                r.revoked = true;
136                Ok(())
137            }
138            None => Err(OtpError::NotFound),
139        }
140    }
141
142    async fn cleanup_expired(&self) -> OtpResult<u64> {
143        let now = Utc::now();
144        let mut records = self.records.write();
145        let before = records.len();
146        records.retain(|_, r| r.expires_at > now);
147        let removed = (before - records.len()) as u64;
148        if removed > 0 {
149            info!(removed, "cleaned up expired OTP records");
150        }
151        Ok(removed)
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Database implementation (production)
157// ---------------------------------------------------------------------------
158
159/// Database-backed OTP store for production use.
160///
161/// Stores OTP records in kipuka's configured database (SQLite, PostgreSQL,
162/// or MariaDB via `sqlx`). Hash-indexed for O(1) lookup during validation.
163///
164/// Token hashes are stored as hex-encoded strings in the `token_hash` TEXT
165/// column.  The `id` column is a database auto-increment integer; the
166/// `OtpRecord.id` UUID is mapped bijectively via `Uuid::from_u128`.
167#[cfg(feature = "db")]
168pub struct DbOtpStore {
169    pool: sqlx::AnyPool,
170    /// Whether the backend is PostgreSQL (requires `$N` parameter style).
171    is_postgres: bool,
172}
173
174#[cfg(feature = "db")]
175impl DbOtpStore {
176    /// Create a database-backed store.
177    ///
178    /// The `is_postgres` flag controls parameter placeholder style:
179    /// PostgreSQL requires `$1, $2, …` while SQLite and MariaDB use `?`.
180    pub fn new(pool: sqlx::AnyPool, is_postgres: bool) -> Self {
181        Self { pool, is_postgres }
182    }
183
184    /// Rewrite `?` placeholders to `$1, $2, …` when running against PostgreSQL.
185    ///
186    /// sqlx 0.8's `AnyPool` does not reliably rewrite placeholders for
187    /// PostgreSQL.  This mirrors the `pg_sql` helper from the main crate.
188    fn sql(&self, s: &str) -> String {
189        if !self.is_postgres {
190            return s.to_string();
191        }
192        let mut result = String::with_capacity(s.len() + 16);
193        let mut param_num = 0u32;
194        for ch in s.chars() {
195            if ch == '?' {
196                param_num += 1;
197                result.push('$');
198                result.push_str(&param_num.to_string());
199            } else {
200                result.push(ch);
201            }
202        }
203        result
204    }
205
206    /// Convert a database auto-increment `id` to a UUID for in-memory use.
207    fn id_to_uuid(db_id: i64) -> Uuid {
208        Uuid::from_u128(db_id as u128)
209    }
210
211    /// Extract the database auto-increment `id` from an in-memory UUID.
212    fn uuid_to_id(uuid: &Uuid) -> i64 {
213        uuid.as_u128() as i64
214    }
215}
216
217#[cfg(feature = "db")]
218impl OtpStore for DbOtpStore {
219    async fn insert(&self, record: OtpRecord) -> OtpResult<()> {
220        let token_hash_hex = hex::encode(&record.token_hash);
221        let expires_at_str = record.expires_at.to_rfc3339();
222        let created_at_str = record.created_at.to_rfc3339();
223
224        debug!(
225            entity_id = %record.entity_id,
226            label = %record.label,
227            profile = %record.profile,
228            max_uses = record.max_uses,
229            "inserting OTP record into database"
230        );
231
232        let sql = self.sql(
233            "INSERT INTO otp_tokens (token_hash, entity_id, label, profile, created_at, expires_at, max_uses, current_uses, revoked) \
234             VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)",
235        );
236
237        sqlx::query(sqlx::AssertSqlSafe(sql))
238            .bind(&token_hash_hex)
239            .bind(&record.entity_id)
240            .bind(&record.label)
241            .bind(&record.profile)
242            .bind(&created_at_str)
243            .bind(&expires_at_str)
244            .bind(record.max_uses as i32)
245            .execute(&self.pool)
246            .await
247            .map_err(|e| OtpError::StorageError(format!("insert failed: {e}")))?;
248
249        info!(entity_id = %record.entity_id, "OTP record stored in database");
250        Ok(())
251    }
252
253    async fn find_by_hash(&self, hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
254        let hash_hex = hex::encode(hash);
255
256        let sql = self.sql(
257            "SELECT id, token_hash, entity_id, label, profile, created_at, expires_at, max_uses, current_uses, revoked \
258             FROM otp_tokens WHERE token_hash = ? AND revoked = 0",
259        );
260
261        let row = sqlx::query_as::<
262            _,
263            (
264                i64,    // id
265                String, // token_hash (hex)
266                String, // entity_id
267                String, // label
268                String, // profile
269                String, // created_at
270                String, // expires_at
271                i32,    // max_uses
272                i32,    // current_uses
273                i32,    // revoked (integer 0/1 for SQLite compat)
274            ),
275        >(sqlx::AssertSqlSafe(sql))
276        .bind(&hash_hex)
277        .fetch_optional(&self.pool)
278        .await
279        .map_err(|e| OtpError::StorageError(format!("find_by_hash query failed: {e}")))?;
280
281        match row {
282            None => Ok(None),
283            Some((
284                db_id,
285                token_hash_hex,
286                entity_id,
287                label,
288                profile,
289                created_str,
290                expires_str,
291                max_uses,
292                current_uses,
293                revoked,
294            )) => {
295                let token_hash = hex::decode(&token_hash_hex).map_err(|e| {
296                    OtpError::StorageError(format!("invalid hex in token_hash: {e}"))
297                })?;
298
299                let created_at = chrono::DateTime::parse_from_rfc3339(&created_str)
300                    .map(|dt| dt.with_timezone(&Utc))
301                    .map_err(|e| OtpError::StorageError(format!("invalid created_at: {e}")))?;
302
303                let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_str)
304                    .map(|dt| dt.with_timezone(&Utc))
305                    .map_err(|e| OtpError::StorageError(format!("invalid expires_at: {e}")))?;
306
307                Ok(Some(OtpRecord {
308                    id: Self::id_to_uuid(db_id),
309                    token_hash,
310                    entity_id,
311                    label,
312                    profile,
313                    created_at,
314                    expires_at,
315                    max_uses: max_uses as u32,
316                    current_uses: current_uses as u32,
317                    revoked: revoked != 0,
318                }))
319            }
320        }
321    }
322
323    async fn increment_uses(&self, id: &Uuid, max_uses: u32) -> OtpResult<()> {
324        let db_id = Self::uuid_to_id(id);
325
326        // Atomic increment: only succeeds when current_uses < max_uses,
327        // preventing double-spend races between concurrent validators.
328        let sql = self.sql(
329            "UPDATE otp_tokens SET current_uses = current_uses + 1 \
330             WHERE id = ? AND current_uses < ?",
331        );
332
333        let result = sqlx::query(sqlx::AssertSqlSafe(sql))
334            .bind(db_id)
335            .bind(max_uses as i32)
336            .execute(&self.pool)
337            .await
338            .map_err(|e| OtpError::StorageError(format!("increment_uses failed: {e}")))?;
339
340        if result.rows_affected() == 0 {
341            // The OTP was already fully consumed by a concurrent request.
342            return Err(OtpError::UsageLimitExceeded { max_uses });
343        }
344
345        debug!(db_id, max_uses, "OTP usage count atomically incremented");
346        Ok(())
347    }
348
349    async fn revoke(&self, id: &Uuid) -> OtpResult<()> {
350        let db_id = Self::uuid_to_id(id);
351        let now_str = Utc::now().to_rfc3339();
352
353        let sql = self.sql("UPDATE otp_tokens SET revoked = 1, revoked_at = ? WHERE id = ?");
354
355        let result = sqlx::query(sqlx::AssertSqlSafe(sql))
356            .bind(&now_str)
357            .bind(db_id)
358            .execute(&self.pool)
359            .await
360            .map_err(|e| OtpError::StorageError(format!("revoke failed: {e}")))?;
361
362        if result.rows_affected() == 0 {
363            return Err(OtpError::NotFound);
364        }
365
366        info!(db_id, "OTP record revoked");
367        Ok(())
368    }
369
370    async fn cleanup_expired(&self) -> OtpResult<u64> {
371        let now_str = Utc::now().to_rfc3339();
372
373        let sql = self.sql("DELETE FROM otp_tokens WHERE expires_at < ?");
374
375        let result = sqlx::query(sqlx::AssertSqlSafe(sql))
376            .bind(&now_str)
377            .execute(&self.pool)
378            .await
379            .map_err(|e| OtpError::StorageError(format!("cleanup_expired failed: {e}")))?;
380
381        let removed = result.rows_affected();
382        if removed > 0 {
383            info!(removed, "cleaned up expired OTP records from database");
384        }
385        Ok(removed)
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Stub (no-db feature)
391// ---------------------------------------------------------------------------
392
393/// Stub database-backed OTP store when the `db` feature is disabled.
394///
395/// All methods return a "not implemented" error.
396#[cfg(not(feature = "db"))]
397pub struct DbOtpStore {
398    _private: (),
399}
400
401#[cfg(not(feature = "db"))]
402impl DbOtpStore {
403    /// Create a stub store (database support not compiled in).
404    pub fn new() -> Self {
405        Self { _private: () }
406    }
407}
408
409#[cfg(not(feature = "db"))]
410impl Default for DbOtpStore {
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416#[cfg(not(feature = "db"))]
417impl OtpStore for DbOtpStore {
418    async fn insert(&self, _record: OtpRecord) -> OtpResult<()> {
419        Err(OtpError::StorageError(
420            "database OTP store not compiled (enable 'db' feature)".into(),
421        ))
422    }
423
424    async fn find_by_hash(&self, _hash: &[u8]) -> OtpResult<Option<OtpRecord>> {
425        Err(OtpError::StorageError(
426            "database OTP store not compiled (enable 'db' feature)".into(),
427        ))
428    }
429
430    async fn increment_uses(&self, _id: &Uuid, _max_uses: u32) -> OtpResult<()> {
431        Err(OtpError::StorageError(
432            "database OTP store not compiled (enable 'db' feature)".into(),
433        ))
434    }
435
436    async fn revoke(&self, _id: &Uuid) -> OtpResult<()> {
437        Err(OtpError::StorageError(
438            "database OTP store not compiled (enable 'db' feature)".into(),
439        ))
440    }
441
442    async fn cleanup_expired(&self) -> OtpResult<u64> {
443        Err(OtpError::StorageError(
444            "database OTP store not compiled (enable 'db' feature)".into(),
445        ))
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::generate::{OtpGenerator, OtpGeneratorConfig};
453
454    #[tokio::test]
455    async fn in_memory_store_round_trip() {
456        let store = InMemoryOtpStore::new();
457        let generator = OtpGenerator::new(OtpGeneratorConfig::default()).unwrap();
458        let otp = generator
459            .generate("host.example.com", "test", "default")
460            .unwrap();
461
462        let record = OtpRecord {
463            id: otp.metadata.id,
464            token_hash: otp.token_hash.clone(),
465            entity_id: otp.metadata.entity_id.clone(),
466            label: otp.metadata.label.clone(),
467            profile: otp.metadata.profile.clone(),
468            created_at: otp.metadata.created_at,
469            expires_at: otp.metadata.expires_at,
470            max_uses: otp.metadata.max_uses,
471            current_uses: 0,
472            revoked: false,
473        };
474
475        store.insert(record).await.unwrap();
476
477        let found = store.find_by_hash(&otp.token_hash).await.unwrap();
478        assert!(found.is_some());
479        assert_eq!(found.unwrap().entity_id, "host.example.com");
480    }
481
482    #[tokio::test]
483    async fn cleanup_removes_expired() {
484        let store = InMemoryOtpStore::new();
485        let expired = OtpRecord {
486            id: Uuid::new_v4(),
487            token_hash: vec![0u8; 32],
488            entity_id: "expired.example.com".into(),
489            label: "expired".into(),
490            profile: "default".into(),
491            created_at: Utc::now() - chrono::Duration::hours(2),
492            expires_at: Utc::now() - chrono::Duration::hours(1),
493            max_uses: 1,
494            current_uses: 0,
495            revoked: false,
496        };
497        store.insert(expired).await.unwrap();
498
499        let removed = store.cleanup_expired().await.unwrap();
500        assert_eq!(removed, 1);
501    }
502}