vex_persist/
api_key_store.rs

1//! API Key storage and validation
2//!
3//! Provides database-backed API key management with hashing and lookup.
4
5use argon2::password_hash::{rand_core::OsRng, SaltString};
6use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use subtle::ConstantTimeEq;
11use thiserror::Error;
12use uuid::Uuid;
13
14/// API key errors
15#[derive(Debug, Error)]
16pub enum ApiKeyError {
17    #[error("Storage error: {0}")]
18    Storage(String),
19    #[error("Key not found")]
20    NotFound,
21    #[error("Key expired")]
22    Expired,
23    #[error("Key revoked")]
24    Revoked,
25    #[error("Invalid key format")]
26    InvalidFormat,
27}
28
29/// An API key record stored in the database
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ApiKeyRecord {
32    /// Unique key ID (not the actual key)
33    pub id: Uuid,
34    /// Argon2id hash of the API key (includes salt, never store plaintext)
35    pub key_hash: String,
36    /// First 12 characters of key for identification (safe to show)
37    pub key_prefix: String,
38    /// User ID this key belongs to
39    pub user_id: String,
40    /// Human-readable name for the key
41    pub name: String,
42    /// Scopes/permissions granted
43    pub scopes: Vec<String>,
44    /// When the key was created
45    pub created_at: DateTime<Utc>,
46    /// When the key expires (None = never)
47    pub expires_at: Option<DateTime<Utc>>,
48    /// When the key was last used
49    pub last_used_at: Option<DateTime<Utc>>,
50    /// Whether the key is revoked
51    pub revoked: bool,
52}
53
54impl ApiKeyRecord {
55    /// Create a new API key record (does not store it)
56    /// Returns (record, plaintext_key) - the plaintext key is only available once!
57    pub fn new(
58        user_id: &str,
59        name: &str,
60        scopes: Vec<String>,
61        expires_in_days: Option<u32>,
62    ) -> (Self, String) {
63        let id = Uuid::new_v4();
64
65        // Generate a secure random key: vex_<uuid>_<random> (2025 best practice)
66        use rand::distributions::{Alphanumeric, DistString};
67        let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), 32);
68        let plaintext_key = format!("vex_{}_{}", id.to_string().replace("-", ""), random_part);
69
70        // Hash the key for storage
71        let key_hash = Self::hash_key(&plaintext_key);
72        let key_prefix = plaintext_key.chars().take(12).collect();
73
74        let expires_at =
75            expires_in_days.map(|days| Utc::now() + chrono::Duration::days(days as i64));
76
77        let record = Self {
78            id,
79            key_hash,
80            key_prefix,
81            user_id: user_id.to_string(),
82            name: name.to_string(),
83            scopes,
84            created_at: Utc::now(),
85            expires_at,
86            last_used_at: None,
87            revoked: false,
88        };
89
90        (record, plaintext_key)
91    }
92
93    /// Hash an API key for secure storage using Argon2id with random salt
94    /// Returns the PHC-formatted hash string (includes salt)
95    pub fn hash_key(key: &str) -> String {
96        let salt = SaltString::generate(&mut OsRng);
97        let argon2 = Argon2::default();
98        argon2
99            .hash_password(key.as_bytes(), &salt)
100            .expect("Argon2 hashing should not fail")
101            .to_string()
102    }
103
104    /// Verify a plaintext key against a stored Argon2id hash
105    /// Uses constant-time comparison to prevent timing attacks
106    pub fn verify_key(plaintext_key: &str, stored_hash: &str) -> bool {
107        match PasswordHash::new(stored_hash) {
108            Ok(parsed_hash) => Argon2::default()
109                .verify_password(plaintext_key.as_bytes(), &parsed_hash)
110                .is_ok(),
111            Err(_) => {
112                // Legacy SHA-256 hash fallback (for migration)
113                // Use constant-time comparison
114                let legacy_hash = {
115                    use sha2::{Digest, Sha256};
116                    let mut hasher = Sha256::new();
117                    hasher.update(plaintext_key.as_bytes());
118                    hex::encode(hasher.finalize())
119                };
120                legacy_hash.as_bytes().ct_eq(stored_hash.as_bytes()).into()
121            }
122        }
123    }
124
125    /// Check if this key is valid (not expired or revoked)
126    pub fn is_valid(&self) -> bool {
127        if self.revoked {
128            return false;
129        }
130        if let Some(expires) = self.expires_at {
131            if Utc::now() > expires {
132                return false;
133            }
134        }
135        true
136    }
137
138    /// Check if this key has a specific scope
139    pub fn has_scope(&self, scope: &str) -> bool {
140        self.scopes.iter().any(|s| s == scope || s == "*")
141    }
142}
143
144/// API key storage trait
145#[async_trait]
146pub trait ApiKeyStore: Send + Sync {
147    /// Store a new API key
148    async fn create(&self, record: &ApiKeyRecord) -> Result<(), ApiKeyError>;
149
150    /// Find a key by its hash (for legacy SHA-256 compatibility)
151    async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKeyRecord>, ApiKeyError>;
152
153    /// Find and verify a key using Argon2
154    /// Recommended approach: extract key ID from prefix for O(1) lookup
155    async fn find_and_verify_key(
156        &self,
157        plaintext_key: &str,
158    ) -> Result<Option<ApiKeyRecord>, ApiKeyError>;
159
160    /// Find all keys for a user
161    async fn find_by_user(&self, user_id: &str) -> Result<Vec<ApiKeyRecord>, ApiKeyError>;
162
163    /// Update last_used_at timestamp
164    async fn record_usage(&self, id: Uuid) -> Result<(), ApiKeyError>;
165
166    /// Revoke a key
167    async fn revoke(&self, id: Uuid) -> Result<(), ApiKeyError>;
168
169    /// Delete a key
170    async fn delete(&self, id: Uuid) -> Result<(), ApiKeyError>;
171
172    /// Rotate a key: creates a new key and revokes the old one
173    ///
174    /// # Arguments
175    /// * `old_key_id` - The ID of the key to rotate
176    /// * `expires_in_days` - TTL for the new key (default: 90 days)
177    ///
178    /// # Returns
179    /// * `(new_record, plaintext_key)` - The new record and plaintext key (shown once!)
180    async fn rotate(
181        &self,
182        old_key_id: Uuid,
183        expires_in_days: Option<u32>,
184    ) -> Result<(ApiKeyRecord, String), ApiKeyError>;
185}
186
187/// In-memory implementation of API key store (for testing)
188#[derive(Debug, Default)]
189pub struct MemoryApiKeyStore {
190    keys: tokio::sync::RwLock<std::collections::HashMap<Uuid, ApiKeyRecord>>,
191}
192
193impl MemoryApiKeyStore {
194    pub fn new() -> Self {
195        Self::default()
196    }
197}
198
199#[async_trait]
200impl ApiKeyStore for MemoryApiKeyStore {
201    async fn create(&self, record: &ApiKeyRecord) -> Result<(), ApiKeyError> {
202        let mut keys = self.keys.write().await;
203        keys.insert(record.id, record.clone());
204        Ok(())
205    }
206
207    async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKeyRecord>, ApiKeyError> {
208        // For legacy SHA-256 hashes, direct comparison is still possible
209        let keys = self.keys.read().await;
210        Ok(keys.values().find(|r| r.key_hash == hash).cloned())
211    }
212
213    async fn find_and_verify_key(
214        &self,
215        plaintext_key: &str,
216    ) -> Result<Option<ApiKeyRecord>, ApiKeyError> {
217        // Extract ID from key: vex_<uuid_compact>_<random>
218        let parts: Vec<&str> = plaintext_key.split('_').collect();
219        if parts.len() < 3 {
220            return Err(ApiKeyError::InvalidFormat);
221        }
222
223        let uuid_str = parts[1];
224        if uuid_str.len() != 32 {
225            return Err(ApiKeyError::InvalidFormat);
226        }
227
228        // Reconstruct UUID (with hyphens for parsing)
229        let formatted_uuid = format!(
230            "{}-{}-{}-{}-{}",
231            &uuid_str[0..8],
232            &uuid_str[8..12],
233            &uuid_str[12..16],
234            &uuid_str[16..20],
235            &uuid_str[20..32]
236        );
237
238        let id = Uuid::parse_str(&formatted_uuid).map_err(|_| ApiKeyError::InvalidFormat)?;
239
240        let keys = self.keys.read().await;
241        if let Some(record) = keys.get(&id) {
242            if ApiKeyRecord::verify_key(plaintext_key, &record.key_hash) {
243                return Ok(Some(record.clone()));
244            }
245        }
246
247        Ok(None)
248    }
249
250    async fn find_by_user(&self, user_id: &str) -> Result<Vec<ApiKeyRecord>, ApiKeyError> {
251        let keys = self.keys.read().await;
252        Ok(keys
253            .values()
254            .filter(|r| r.user_id == user_id)
255            .cloned()
256            .collect())
257    }
258
259    async fn record_usage(&self, id: Uuid) -> Result<(), ApiKeyError> {
260        let mut keys = self.keys.write().await;
261        if let Some(record) = keys.get_mut(&id) {
262            record.last_used_at = Some(Utc::now());
263            Ok(())
264        } else {
265            Err(ApiKeyError::NotFound)
266        }
267    }
268
269    async fn revoke(&self, id: Uuid) -> Result<(), ApiKeyError> {
270        let mut keys = self.keys.write().await;
271        if let Some(record) = keys.get_mut(&id) {
272            record.revoked = true;
273            Ok(())
274        } else {
275            Err(ApiKeyError::NotFound)
276        }
277    }
278
279    async fn delete(&self, id: Uuid) -> Result<(), ApiKeyError> {
280        let mut keys = self.keys.write().await;
281        keys.remove(&id).ok_or(ApiKeyError::NotFound)?;
282        Ok(())
283    }
284
285    async fn rotate(
286        &self,
287        old_key_id: Uuid,
288        expires_in_days: Option<u32>,
289    ) -> Result<(ApiKeyRecord, String), ApiKeyError> {
290        // Get old key to copy user_id, name, and scopes
291        let old_key = {
292            let keys = self.keys.read().await;
293            keys.get(&old_key_id)
294                .cloned()
295                .ok_or(ApiKeyError::NotFound)?
296        };
297
298        // Revoke old key first
299        self.revoke(old_key_id).await?;
300
301        // Create new key with same user, name suffix, and scopes
302        let ttl = expires_in_days.unwrap_or(90); // Default 90 days per 2025 best practices
303        let (new_record, plaintext) = ApiKeyRecord::new(
304            &old_key.user_id,
305            &format!("{} (rotated)", old_key.name),
306            old_key.scopes.clone(),
307            Some(ttl),
308        );
309
310        // Store new key
311        self.create(&new_record).await?;
312
313        tracing::info!(
314            old_key_id = %old_key_id,
315            new_key_id = %new_record.id,
316            user_id = %old_key.user_id,
317            "API key rotated successfully"
318        );
319
320        Ok((new_record, plaintext))
321    }
322}
323
324/// Validate an API key and return the associated record if valid
325pub async fn validate_api_key<S: ApiKeyStore>(
326    store: &S,
327    plaintext_key: &str,
328) -> Result<ApiKeyRecord, ApiKeyError> {
329    // Validate format
330    if !plaintext_key.starts_with("vex_") || plaintext_key.len() < 40 {
331        return Err(ApiKeyError::InvalidFormat);
332    }
333
334    // Find and verify using Argon2 (handles both new and legacy hashes)
335    let record = store
336        .find_and_verify_key(plaintext_key)
337        .await?
338        .ok_or(ApiKeyError::NotFound)?;
339
340    // Check validity
341    if record.revoked {
342        return Err(ApiKeyError::Revoked);
343    }
344    if let Some(expires) = record.expires_at {
345        if Utc::now() > expires {
346            return Err(ApiKeyError::Expired);
347        }
348    }
349
350    // Record usage (fire-and-forget)
351    let _ = store.record_usage(record.id).await;
352
353    Ok(record)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[tokio::test]
361    async fn test_api_key_creation() {
362        let (record, key) =
363            ApiKeyRecord::new("user123", "My API Key", vec!["read".to_string()], None);
364
365        assert!(key.starts_with("vex_"));
366        assert!(key.len() > 40);
367        assert_eq!(record.user_id, "user123");
368        assert_eq!(record.name, "My API Key");
369        assert!(record.is_valid());
370    }
371
372    #[tokio::test]
373    async fn test_api_key_hash_verification() {
374        // With Argon2id, hashes are different each time (due to random salt)
375        // Instead, we test that verify_key correctly validates
376        let key = "vex_test123456789_abcdefghijklmnopqrst";
377        let hash = ApiKeyRecord::hash_key(key);
378
379        // Same key should verify against its hash
380        assert!(ApiKeyRecord::verify_key(key, &hash));
381
382        // Different key should not verify
383        assert!(!ApiKeyRecord::verify_key(
384            "vex_wrong_key_12345678901234567890",
385            &hash
386        ));
387    }
388
389    #[tokio::test]
390    async fn test_memory_store_crud() {
391        let store = MemoryApiKeyStore::new();
392        let (record, key) = ApiKeyRecord::new("user1", "Test Key", vec![], None);
393
394        // Create
395        store.create(&record).await.unwrap();
396
397        // Find and verify key (uses Argon2 verification)
398        let found = store.find_and_verify_key(&key).await.unwrap();
399        assert!(found.is_some());
400        assert_eq!(found.unwrap().id, record.id);
401
402        // Revoke
403        store.revoke(record.id).await.unwrap();
404        let revoked = store.find_and_verify_key(&key).await.unwrap().unwrap();
405        assert!(revoked.revoked);
406    }
407
408    #[tokio::test]
409    async fn test_validate_api_key() {
410        let store = MemoryApiKeyStore::new();
411        let (record, key) = ApiKeyRecord::new("user1", "Test Key", vec!["admin".to_string()], None);
412        store.create(&record).await.unwrap();
413
414        // Valid key should work
415        let validated = validate_api_key(&store, &key).await.unwrap();
416        assert_eq!(validated.id, record.id);
417        assert!(validated.has_scope("admin"));
418
419        // Invalid format should fail
420        let result = validate_api_key(&store, "invalid").await;
421        assert!(matches!(result, Err(ApiKeyError::InvalidFormat)));
422
423        // Wrong key should fail
424        let result =
425            validate_api_key(&store, "vex_00000000000000000000000000000000_wrongkey").await;
426        assert!(matches!(result, Err(ApiKeyError::NotFound)));
427    }
428}