api_keys_simplified/domain.rs
1use crate::error::InitError;
2use crate::validator::KeyStatus;
3use crate::{
4 config::{Environment, KeyConfig, KeyPrefix},
5 error::Result,
6 generator::KeyGenerator,
7 hasher::KeyHasher,
8 secure::SecureString,
9 validator::KeyValidator,
10 ExposeSecret, HashConfig,
11};
12use chrono::{DateTime, Utc};
13use derive_getters::Getters;
14use std::fmt::Debug;
15
16/// ApiKeyManager is storable object
17/// used to generate and verify API keys.
18/// It contains immutable config data necessary
19/// to operate. It does NOT contain ANY sensitive
20/// data.
21#[derive(Clone, Getters)]
22pub struct ApiKeyManagerV0 {
23 #[getter(skip)]
24 generator: KeyGenerator,
25 hasher: KeyHasher,
26 #[getter(skip)]
27 validator: KeyValidator,
28 #[getter(skip)]
29 include_checksum: bool,
30 #[getter(skip)]
31 expiry_grace_period: std::time::Duration,
32}
33
34// FIXME: Need better naming
35/// Contains the Argon2 hash in PHC format and a stable key identifier.
36///
37/// The hash can be safely stored in your database without special security measures
38/// since it's already cryptographically hashed. However, avoid unnecessary cloning
39/// or logging to minimize exposure.
40///
41/// # Fields
42///
43/// - `key_id`: A stable, deterministic identifier derived from the API key itself.
44/// This ID never changes for the same key, making it perfect for database indexing
45/// and key lookups. Format: 32 hex characters (16 bytes of BLAKE3 hash).
46///
47/// - `hash`: The Argon2id hash in PHC format. This changes each time you hash the
48/// same key (due to random salt), but the key_id remains constant.
49///
50/// # PHC Format
51///
52/// The hash is stored in PHC (Password Hashing Competition) format which includes:
53/// - Algorithm identifier (argon2id)
54/// - Version
55/// - Parameters (memory cost, time cost, parallelism)
56/// - Salt (base64-encoded, embedded in the hash string)
57/// - Hash output (base64-encoded)
58///
59/// Example: `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`
60///
61/// The salt is embedded within the PHC string and can be extracted if needed using
62/// the `password_hash` crate's `PasswordHash::new()` method.
63///
64/// # Key ID vs Hash
65///
66/// - **Key ID**: Stable identifier, never changes for the same key
67/// - **Hash**: Changes each time you hash (due to different random salts)
68///
69/// Both fields can be accessed using the auto-generated getter methods `key_id()` and `hash()`
70/// provided by the `Getters` derive macro.
71#[derive(Debug, Getters, PartialEq)]
72pub struct Hash {
73 key_id: String,
74 hash: String,
75}
76
77#[derive(Debug)]
78pub struct NoHash;
79
80/// Represents a generated API key with its hash.
81///
82/// The key field is stored in a `SecureString` which automatically zeros
83/// its memory on drop, preventing potential memory disclosure.
84#[derive(Debug)]
85pub struct ApiKey<Hash> {
86 key: SecureString,
87 hash: Hash,
88}
89
90impl ApiKeyManagerV0 {
91 pub fn init(
92 prefix: impl Into<String>,
93 config: KeyConfig,
94 hash_config: HashConfig,
95 expiry_grace_period: std::time::Duration,
96 ) -> std::result::Result<Self, InitError> {
97 let include_checksum = *config.checksum_length() != 0;
98 let prefix = KeyPrefix::new(prefix)?;
99 let generator = KeyGenerator::new(prefix, config)?;
100 let hasher = KeyHasher::new(hash_config);
101
102 // Generate dummy key and its hash for timing attack protection
103 let dummy_key = generator.dummy_key().clone();
104 let (_dummy_key_id, dummy_hash) = hasher.hash(&dummy_key)?;
105
106 let validator = KeyValidator::new(include_checksum, dummy_key, dummy_hash)?;
107
108 Ok(Self {
109 generator,
110 hasher,
111 validator,
112 include_checksum,
113 expiry_grace_period,
114 })
115 }
116
117 pub fn init_default_config(prefix: impl Into<String>) -> std::result::Result<Self, InitError> {
118 Self::init(
119 prefix,
120 KeyConfig::default(),
121 HashConfig::default(),
122 std::time::Duration::from_secs(10),
123 )
124 }
125 pub fn init_high_security_config(
126 prefix: impl Into<String>,
127 ) -> std::result::Result<Self, InitError> {
128 Self::init(
129 prefix,
130 KeyConfig::high_security(),
131 HashConfig::high_security(),
132 std::time::Duration::from_secs(10),
133 )
134 }
135
136 /// Generates a new API key for the specified environment.
137 ///
138 /// The generated key includes a checksum (if enabled) for fast DoS protection.
139 ///
140 /// # Example
141 ///
142 /// ```rust
143 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
144 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
145 /// let key = manager.generate(Environment::production())?;
146 /// println!("Key: {}", key.key().expose_secret());
147 /// # Ok::<(), Box<dyn std::error::Error>>(())
148 /// ```
149 pub fn generate(&self, environment: impl Into<Environment>) -> Result<ApiKey<Hash>> {
150 let key = self.generator.generate(environment.into(), None)?;
151 let api_key = ApiKey::new(key).into_hashed(&self.hasher)?;
152
153 Ok(api_key)
154 }
155
156 /// Generates a new API key with an expiration timestamp.
157 ///
158 /// The expiration is embedded in the key itself, making it stateless.
159 /// Keys are automatically rejected after the expiry time without database lookups.
160 ///
161 /// # Use Cases
162 ///
163 /// - Trial keys (7-30 days)
164 /// - Temporary partner access
165 /// - Time-limited API access
166 ///
167 /// # Example
168 ///
169 /// ```rust
170 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment};
171 /// # use chrono::{Utc, Duration};
172 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
173 /// // Create a 7-day trial key
174 /// let expiry = Utc::now() + Duration::days(7);
175 /// let key = manager.generate_with_expiry(Environment::production(), expiry)?;
176 /// # Ok::<(), Box<dyn std::error::Error>>(())
177 /// ```
178 pub fn generate_with_expiry(
179 &self,
180 environment: impl Into<Environment>,
181 expiry: DateTime<Utc>,
182 ) -> Result<ApiKey<Hash>> {
183 let key = self.generator.generate(environment.into(), Some(expiry))?;
184 let api_key = ApiKey::new(key).into_hashed(&self.hasher)?;
185
186 Ok(api_key)
187 }
188
189 /// Verifies an API key against a stored hash.
190 ///
191 /// Returns `KeyStatus` indicating whether the key is valid or invalid.
192 ///
193 /// # Parameters
194 ///
195 /// * `key` - The API key to verify
196 /// * `stored_hash` - The Argon2 hash stored in your database
197 /// * `expiry_grace_period` - Optional grace period duration after expiry.
198 /// - `None`: Skip expiry validation (all keys treated as non-expired)
199 /// - `Some(Duration::ZERO)`: Strict expiry check (no grace period)
200 /// - `Some(duration)`: Key remains valid for `duration` after its expiry time
201 ///
202 /// The grace period protects against clock skew issues. Once a key expires beyond
203 /// the grace period, it stays expired even if the system clock goes backwards.
204 ///
205 /// # Security Flow
206 ///
207 /// 1. **Checksum validation** (if enabled): Rejects invalid keys in ~20μs
208 /// 2. **Argon2 verification**: Verifies hash for valid checksums (~300ms)
209 /// 3. **Expiry check**: Returns `Invalid` if expired beyond grace period
210 ///
211 /// # Returns
212 ///
213 /// - `KeyStatus::Valid` - Key is valid and not expired
214 /// - `KeyStatus::Invalid` - Key is invalid (wrong key, hash mismatch, checksum failed, or expired)
215 ///
216 /// # Note on Revocation
217 ///
218 /// This method does NOT check revocation status. To implement key revocation:
219 /// 1. Mark the hash as revoked in your database
220 /// 2. Check revocation status before calling this method
221 /// 3. Only call `verify()` for non-revoked hashes
222 ///
223 /// # Example
224 ///
225 /// ```rust
226 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment, KeyStatus};
227 /// # use std::time::Duration;
228 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
229 /// # let key = manager.generate(Environment::production()).unwrap();
230 /// match manager.verify(key.key(), key.expose_hash().hash())? {
231 /// KeyStatus::Valid => { /* grant access */ },
232 /// KeyStatus::Invalid => { /* reject - wrong key or expired */ },
233 /// }
234 /// # Ok::<(), Box<dyn std::error::Error>>(())
235 /// ```
236 pub fn verify(&self, key: &SecureString, stored_hash: impl AsRef<str>) -> Result<KeyStatus> {
237 if self.include_checksum && !self.verify_checksum(key)? {
238 return Ok(KeyStatus::Invalid);
239 }
240
241 self.validator.verify(
242 key.expose_secret(),
243 stored_hash.as_ref(),
244 self.expiry_grace_period,
245 )
246 }
247
248 pub fn verify_checksum(&self, key: &SecureString) -> Result<bool> {
249 self.generator.verify_checksum(key)
250 }
251
252 /// Extracts a stable key ID from an API key.
253 ///
254 /// This generates a deterministic identifier from the API key using BLAKE3 hash.
255 /// The same key will always produce the same key ID, making it perfect for:
256 /// - Database lookups and indexing
257 /// - Key identification without exposing the full key
258 /// - Tracking key usage across hash rotations
259 ///
260 /// # Format
261 ///
262 /// Returns a 32-character hex string (16 bytes / 128 bits of BLAKE3 hash).
263 ///
264 /// # Example
265 ///
266 /// ```rust
267 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment, SecureString};
268 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
269 /// // Extract key ID from an incoming API key (e.g., from Authorization header)
270 /// let provided_key = SecureString::from("sk-live-abc123def456...".to_string());
271 /// let key_id = manager.extract_key_id(&provided_key);
272 ///
273 /// // Use key_id for fast database lookup
274 /// // let stored_hash = database.find_by_key_id(&key_id)?;
275 /// // manager.verify(&provided_key, &stored_hash)?;
276 /// # Ok::<(), Box<dyn std::error::Error>>(())
277 /// ```
278 ///
279 /// # Security Note
280 ///
281 /// While the key ID is a one-way hash, it still uniquely identifies a key.
282 /// Treat it as sensitive data and don't expose it in public APIs or logs.
283 pub fn extract_key_id(&self, key: &SecureString) -> String {
284 self.hasher.generate_key_id(key)
285 }
286}
287
288impl<T> ApiKey<T> {
289 /// Returns a reference to the secure API key.
290 ///
291 /// To access the underlying string, use `.expose_secret()` on the returned `SecureString`:
292 ///
293 /// ```rust
294 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
295 /// # let generator = ApiKeyManagerV0::init_default_config("sk").unwrap();
296 /// # let api_key = generator.generate(Environment::production()).unwrap();
297 /// let key_str: &str = api_key.key().expose_secret();
298 /// ```
299 ///
300 /// # Security Note
301 ///
302 /// The key is stored in secure memory that is automatically zeroed on drop.
303 /// Be careful NOT to clone or log the value unnecessarily.
304 pub fn key(&self) -> &SecureString {
305 &self.key
306 }
307}
308
309impl ApiKey<NoHash> {
310 /// Creates a new API key without a hash.
311 ///
312 /// This is typically used internally before converting to a hashed key.
313 pub fn new(key: SecureString) -> ApiKey<NoHash> {
314 ApiKey { key, hash: NoHash }
315 }
316
317 /// Converts this unhashed key into a hashed key by generating a new random salt
318 /// and computing the Argon2 hash.
319 ///
320 /// This method is automatically called by `ApiKeyManagerV0::generate()` and
321 /// `ApiKeyManagerV0::generate_with_expiry()`.
322 pub fn into_hashed(self, hasher: &KeyHasher) -> Result<ApiKey<Hash>> {
323 let (key_id, hash) = hasher.hash(&self.key)?;
324
325 Ok(ApiKey {
326 key: self.key,
327 hash: Hash { key_id, hash },
328 })
329 }
330
331 /// Converts this unhashed key into a hashed key using a specific PHC hash string.
332 ///
333 /// This is useful when you need to regenerate the same hash from the same key,
334 /// for example in testing or when verifying hash consistency. The salt is extracted
335 /// from the provided PHC hash string, and the key ID is derived from the key itself.
336 ///
337 /// # Parameters
338 ///
339 /// * `hasher` - The key hasher to use
340 /// * `phc_hash` - An existing PHC-formatted hash string to extract the salt from
341 ///
342 /// # Example
343 ///
344 /// ```rust
345 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
346 /// # use api_keys_simplified::{SecureString, ApiKey};
347 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
348 /// let key1 = manager.generate(Environment::production()).unwrap();
349 ///
350 /// // Regenerate hash with the same salt (extracted from the PHC hash)
351 /// let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
352 /// .into_hashed_with_phc(manager.hasher(), key1.expose_hash().hash())
353 /// .unwrap();
354 ///
355 /// // Both hashes should be identical
356 /// assert_eq!(key1.expose_hash(), key2.expose_hash());
357 /// # Ok::<(), Box<dyn std::error::Error>>(())
358 /// ```
359 pub fn into_hashed_with_phc(self, hasher: &KeyHasher, phc_hash: &str) -> Result<ApiKey<Hash>> {
360 let (key_id, hash) = hasher.hash_with_phc(&self.key, phc_hash)?;
361
362 Ok(ApiKey {
363 key: self.key,
364 hash: Hash { key_id, hash },
365 })
366 }
367
368 /// Consumes the API key and returns the underlying secure string.
369 pub fn into_key(self) -> SecureString {
370 self.key
371 }
372}
373
374impl ApiKey<Hash> {
375 /// Returns a reference to the hash.
376 ///
377 /// The returned `Hash` struct contains the Argon2 hash string in PHC format.
378 /// The PHC format embeds all necessary information including the salt, algorithm
379 /// parameters, and hash output. This single string should be stored in your
380 /// database for later verification.
381 ///
382 /// # Accessing Fields
383 ///
384 /// Use the auto-generated getter method:
385 /// - `.hash()` - Returns the PHC-formatted hash string as `&str`
386 ///
387 /// # PHC Format
388 ///
389 /// The hash string follows the PHC format:
390 /// `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`
391 ///
392 /// The salt is embedded in the hash string and can be extracted if needed using
393 /// the `password_hash` crate's `PasswordHash::new()` method.
394 ///
395 /// # Security Note
396 ///
397 /// Although it's safe to store the hash, avoid making unnecessary clones
398 /// or logging the hash to minimize exposure.
399 ///
400 /// # Example
401 ///
402 /// ```rust
403 /// # use api_keys_simplified::{ApiKeyManagerV0, Environment};
404 /// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
405 /// # let api_key = manager.generate(Environment::production()).unwrap();
406 /// // Get the hash for storage
407 /// let hash_struct = api_key.expose_hash();
408 ///
409 /// // Access the hash string for database storage
410 /// let hash_str: &str = hash_struct.hash();
411 /// println!("Store this hash: {}", hash_str);
412 /// # Ok::<(), Box<dyn std::error::Error>>(())
413 /// ```
414 pub fn expose_hash(&self) -> &Hash {
415 &self.hash
416 }
417
418 /// Consumes the API key and returns the underlying secure string.
419 pub fn into_key(self) -> SecureString {
420 self.key
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::{ExposeSecret, SecureStringExt};
428
429 #[test]
430 fn test_full_lifecycle() {
431 let generator = ApiKeyManagerV0::init_default_config("sk").unwrap();
432 let api_key = generator.generate(Environment::production()).unwrap();
433
434 let key_str = api_key.key();
435 let hash_str = api_key.expose_hash().hash();
436
437 assert!(key_str.expose_secret().starts_with("sk-live-"));
438 assert!(hash_str.starts_with("$argon2id$"));
439
440 assert_eq!(
441 generator.verify(key_str, hash_str).unwrap(),
442 KeyStatus::Valid
443 );
444 let wrong_key = SecureString::from("wrong_key".to_string());
445 assert_eq!(
446 generator.verify(&wrong_key, hash_str).unwrap(),
447 KeyStatus::Invalid
448 );
449 }
450
451 #[test]
452 fn test_different_presets() {
453 let balanced_gen = ApiKeyManagerV0::init_default_config("pk").unwrap();
454 let balanced = balanced_gen.generate(Environment::test()).unwrap();
455
456 let high_sec_gen = ApiKeyManagerV0::init_high_security_config("sk").unwrap();
457 let high_sec = high_sec_gen.generate(Environment::Production).unwrap();
458
459 assert!(!balanced.key().is_empty());
460 assert!(high_sec.key().len() > balanced.key().len());
461 }
462
463 #[test]
464 fn test_custom_config() {
465 let config = KeyConfig::new().with_entropy(32).unwrap();
466
467 let generator = ApiKeyManagerV0::init(
468 "custom",
469 config,
470 HashConfig::default(),
471 std::time::Duration::ZERO,
472 )
473 .unwrap();
474 let key = generator.generate(Environment::production()).unwrap();
475 assert!(generator.verify_checksum(key.key()).unwrap());
476 }
477
478 #[test]
479 fn compare_hash() {
480 let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
481 let key = manager.generate(Environment::production()).unwrap();
482 let new_secret = ApiKey::new(SecureString::from(key.key().expose_secret()))
483 .into_hashed_with_phc(manager.hasher(), key.expose_hash().hash())
484 .unwrap();
485
486 assert_eq!(new_secret.expose_hash(), key.expose_hash());
487 }
488
489 #[test]
490 fn test_extract_key_id() {
491 let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
492 let key1 = manager.generate(Environment::production()).unwrap();
493 let key2 = manager.generate(Environment::production()).unwrap();
494
495 let id1 = manager.extract_key_id(key1.key());
496
497 // Matches stored key_id
498 assert_eq!(id1, *key1.expose_hash().key_id());
499
500 // Deterministic: multiple extractions match
501 assert_eq!(id1, manager.extract_key_id(key1.key()));
502
503 // Unique: different keys have different IDs
504 assert_ne!(id1, manager.extract_key_id(key2.key()));
505
506 // Format: 32 hex characters
507 assert_eq!(id1.len(), 32);
508 assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
509 }
510
511 #[test]
512 fn test_key_id_stability_across_rehashing() {
513 let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
514 let key1 = manager.generate(Environment::production()).unwrap();
515
516 // Rehash with new salt
517 let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
518 .into_hashed(manager.hasher())
519 .unwrap();
520
521 // Key ID stays the same, hash changes
522 assert_eq!(key1.expose_hash().key_id(), key2.expose_hash().key_id());
523 assert_ne!(key1.expose_hash().hash(), key2.expose_hash().hash());
524 }
525
526 #[test]
527 fn test_key_id_database_lookup() {
528 let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
529 let api_key = manager.generate(Environment::production()).unwrap();
530
531 // Simulate: store in database
532 let stored_key_id = api_key.expose_hash().key_id().to_string();
533 let stored_hash = api_key.expose_hash().hash().to_string();
534
535 // Simulate: incoming request
536 let incoming_key = SecureString::from(api_key.key().expose_secret());
537 let lookup_key_id = manager.extract_key_id(&incoming_key);
538
539 // Fast lookup by key_id, then verify
540 assert_eq!(lookup_key_id, stored_key_id);
541 assert_eq!(
542 manager.verify(&incoming_key, &stored_hash).unwrap(),
543 KeyStatus::Valid
544 );
545 }
546}