Skip to content

Commit 7a5357e

Browse files
committed
fix(validator): add grace period parameter for expiry validation
1 parent 4db6c9d commit 7a5357e

File tree

10 files changed

+91
-60
lines changed

10 files changed

+91
-60
lines changed

crates/api-keys-simplified/src/domain.rs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub struct ApiKeyManagerV0 {
2727
validator: KeyValidator,
2828
#[getter(skip)]
2929
include_checksum: bool,
30+
#[getter(skip)]
31+
expiry_grace_period: std::time::Duration,
3032
}
3133

3234
// FIXME: Need better naming
@@ -53,28 +55,30 @@ impl ApiKeyManagerV0 {
5355
prefix: impl Into<String>,
5456
config: KeyConfig,
5557
hash_config: HashConfig,
58+
expiry_grace_period: std::time::Duration,
5659
) -> std::result::Result<Self, InitError> {
5760
let include_checksum = *config.checksum_length() != 0;
5861
let prefix = KeyPrefix::new(prefix)?;
5962
let generator = KeyGenerator::new(prefix, config)?;
60-
let hasher = KeyHasher::new(hash_config.clone());
63+
let hasher = KeyHasher::new(hash_config);
6164

6265
// Generate dummy key and its hash for timing attack protection
6366
let dummy_key = generator.dummy_key().clone();
6467
let dummy_hash = hasher.hash(&dummy_key)?;
6568

66-
let validator = KeyValidator::new(&hash_config, include_checksum, dummy_key, dummy_hash)?;
69+
let validator = KeyValidator::new(include_checksum, dummy_key, dummy_hash)?;
6770

6871
Ok(Self {
6972
generator,
7073
hasher,
7174
validator,
7275
include_checksum,
76+
expiry_grace_period,
7377
})
7478
}
7579

7680
pub fn init_default_config(prefix: impl Into<String>) -> std::result::Result<Self, InitError> {
77-
Self::init(prefix, KeyConfig::default(), HashConfig::default())
81+
Self::init(prefix, KeyConfig::default(), HashConfig::default(), std::time::Duration::ZERO)
7882
}
7983
pub fn init_high_security_config(
8084
prefix: impl Into<String>,
@@ -83,6 +87,7 @@ impl ApiKeyManagerV0 {
8387
prefix,
8488
KeyConfig::high_security(),
8589
HashConfig::high_security(),
90+
std::time::Duration::ZERO,
8691
)
8792
}
8893

@@ -143,11 +148,23 @@ impl ApiKeyManagerV0 {
143148
///
144149
/// Returns `KeyStatus` indicating whether the key is valid or invalid.
145150
///
151+
/// # Parameters
152+
///
153+
/// * `key` - The API key to verify
154+
/// * `stored_hash` - The Argon2 hash stored in your database
155+
/// * `expiry_grace_period` - Optional grace period duration after expiry.
156+
/// - `None`: Skip expiry validation (all keys treated as non-expired)
157+
/// - `Some(Duration::ZERO)`: Strict expiry check (no grace period)
158+
/// - `Some(duration)`: Key remains valid for `duration` after its expiry time
159+
///
160+
/// The grace period protects against clock skew issues. Once a key expires beyond
161+
/// the grace period, it stays expired even if the system clock goes backwards.
162+
///
146163
/// # Security Flow
147164
///
148165
/// 1. **Checksum validation** (if enabled): Rejects invalid keys in ~20μs
149166
/// 2. **Argon2 verification**: Verifies hash for valid checksums (~300ms)
150-
/// 3. **Expiry check**: Returns `Invalid` if the key's timestamp has passed
167+
/// 3. **Expiry check**: Returns `Invalid` if expired beyond grace period
151168
///
152169
/// # Returns
153170
///
@@ -165,6 +182,7 @@ impl ApiKeyManagerV0 {
165182
///
166183
/// ```rust
167184
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, KeyStatus};
185+
/// # use std::time::Duration;
168186
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
169187
/// # let key = manager.generate(Environment::production()).unwrap();
170188
/// match manager.verify(key.key(), key.hash())? {
@@ -173,13 +191,17 @@ impl ApiKeyManagerV0 {
173191
/// }
174192
/// # Ok::<(), Box<dyn std::error::Error>>(())
175193
/// ```
176-
pub fn verify(&self, key: &SecureString, stored_hash: impl AsRef<str>) -> Result<KeyStatus> {
194+
pub fn verify(
195+
&self,
196+
key: &SecureString,
197+
stored_hash: impl AsRef<str>,
198+
) -> Result<KeyStatus> {
177199
if self.include_checksum && !self.verify_checksum(key)? {
178200
return Ok(KeyStatus::Invalid);
179201
}
180202

181203
self.validator
182-
.verify(key.expose_secret(), stored_hash.as_ref())
204+
.verify(key.expose_secret(), stored_hash.as_ref(), self.expiry_grace_period)
183205
}
184206

185207
pub fn verify_checksum(&self, key: &SecureString) -> Result<bool> {
@@ -282,7 +304,7 @@ mod tests {
282304
fn test_custom_config() {
283305
let config = KeyConfig::new().with_entropy(32).unwrap();
284306

285-
let generator = ApiKeyManagerV0::init("custom", config, HashConfig::default()).unwrap();
307+
let generator = ApiKeyManagerV0::init("custom", config, HashConfig::default(), std::time::Duration::ZERO).unwrap();
286308
let key = generator.generate(Environment::production()).unwrap();
287309
assert!(generator.verify_checksum(key.key()).unwrap());
288310
}

crates/api-keys-simplified/src/generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ mod tests {
374374
#[test]
375375
fn test_verify_checksum_dos_protection() {
376376
let generator =
377-
ApiKeyManagerV0::init("sk", KeyConfig::balanced(), HashConfig::default()).unwrap();
377+
ApiKeyManagerV0::init("sk", KeyConfig::balanced(), HashConfig::default(), std::time::Duration::ZERO).unwrap();
378378

379379
// Test oversized key rejection
380380
let huge_key = SecureString::from("a".repeat(1000));

crates/api-keys-simplified/src/token_parser.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ mod tests {
161161
HashConfig::default()
162162
};
163163

164-
let generator = ApiKeyManagerV0::init("text", config, hash_config).unwrap();
164+
let generator = ApiKeyManagerV0::init("text", config, hash_config, std::time::Duration::ZERO).unwrap();
165165
let ts = Utc::now();
166166
let key = if with_expiry {
167167
generator

crates/api-keys-simplified/src/validator.rs

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ impl KeyValidator {
3333
const MAX_HASH_LENGTH: usize = 512;
3434

3535
pub fn new(
36-
_hash_config: &crate::HashConfig,
3736
has_checksum: bool,
3837
dummy_key: SecureString,
3938
dummy_hash: String,
@@ -48,27 +47,34 @@ impl KeyValidator {
4847
})
4948
}
5049

51-
fn verify_expiry(&self, parts: Parts) -> Result<KeyStatus> {
50+
fn verify_expiry(&self, parts: Parts, expiry_grace_period: std::time::Duration) -> Result<KeyStatus> {
5251
if let Some(expiry) = parts.expiry_b64 {
5352
let decoded = URL_SAFE_NO_PAD
5453
.decode(expiry)
5554
.or(Err(Error::InvalidFormat))?;
56-
let expiry = i64::from_be_bytes(decoded.try_into().or(Err(Error::InvalidFormat))?);
57-
58-
// TODO(ARCHITECTURE): time libs are platform dependent.
59-
// We should set an `infra` layer and abstract
60-
// out these libs.
61-
if chrono::Utc::now().timestamp() <= expiry {
62-
Ok(KeyStatus::Valid)
63-
} else {
64-
Ok(KeyStatus::Invalid)
55+
let expiry_timestamp = i64::from_be_bytes(decoded.try_into().or(Err(Error::InvalidFormat))?);
56+
57+
let current_time = chrono::Utc::now().timestamp();
58+
let grace_seconds = expiry_grace_period.as_secs() as i64;
59+
60+
// Key is invalid if it expired more than grace_period ago
61+
// This ensures once a key expires beyond the grace period, it stays expired
62+
// even if the clock goes backwards
63+
if expiry_timestamp + grace_seconds < current_time {
64+
return Ok(KeyStatus::Invalid);
6565
}
66+
Ok(KeyStatus::Valid)
6667
} else {
6768
Ok(KeyStatus::Valid)
6869
}
6970
}
7071

71-
pub fn verify(&self, provided_key: &str, stored_hash: &str) -> Result<KeyStatus> {
72+
pub fn verify(
73+
&self,
74+
provided_key: &str,
75+
stored_hash: &str,
76+
expiry_grace_period: std::time::Duration,
77+
) -> Result<KeyStatus> {
7278
// Input length validation to prevent DoS attacks
7379
if provided_key.len() > Self::MAX_KEY_LENGTH {
7480
self.dummy_load();
@@ -109,7 +115,7 @@ impl KeyValidator {
109115
// SECURITY: Force evaluation of expiry check BEFORE the match to ensure
110116
// constant-time execution. This prevents the compiler from short-circuiting
111117
// the expiry check when argon_result is Invalid, which would create a timing oracle.
112-
let expiry_result = self.verify_expiry(token_parts)?;
118+
let expiry_result = self.verify_expiry(token_parts, expiry_grace_period)?;
113119

114120
match (argon_result, expiry_result) {
115121
(KeyStatus::Invalid, _) | (_, KeyStatus::Invalid) => Ok(KeyStatus::Invalid),
@@ -151,15 +157,15 @@ mod tests {
151157

152158
let (dummy_key, dummy_hash) = dummy_key_and_hash();
153159
let validator =
154-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
160+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
155161
assert_eq!(
156162
validator
157-
.verify(key.expose_secret(), hash.as_ref())
163+
.verify(key.expose_secret(), hash.as_ref(), std::time::Duration::ZERO)
158164
.unwrap(),
159165
KeyStatus::Valid
160166
);
161167
assert_eq!(
162-
validator.verify("wrong_key", hash.as_ref()).unwrap(),
168+
validator.verify("wrong_key", hash.as_ref(), std::time::Duration::ZERO).unwrap(),
163169
KeyStatus::Invalid
164170
);
165171
}
@@ -168,8 +174,8 @@ mod tests {
168174
fn test_invalid_hash_format() {
169175
let (dummy_key, dummy_hash) = dummy_key_and_hash();
170176
let validator =
171-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
172-
let result = validator.verify("any_key", "invalid_hash");
177+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
178+
let result = validator.verify("any_key", "invalid_hash", std::time::Duration::ZERO);
173179
// After timing oracle fix: invalid hash format returns Ok(Invalid) instead of Err
174180
// to prevent timing-based user enumeration attacks
175181
assert!(result.is_ok());
@@ -185,8 +191,8 @@ mod tests {
185191

186192
let (dummy_key, dummy_hash) = dummy_key_and_hash();
187193
let validator =
188-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
189-
let result = validator.verify(&oversized_key, hash.as_ref());
194+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
195+
let result = validator.verify(&oversized_key, hash.as_ref(), std::time::Duration::ZERO);
190196
assert!(result.is_err());
191197
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
192198
}
@@ -197,8 +203,8 @@ mod tests {
197203

198204
let (dummy_key, dummy_hash) = dummy_key_and_hash();
199205
let validator =
200-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
201-
let result = validator.verify("valid_key", &oversized_hash);
206+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
207+
let result = validator.verify("valid_key", &oversized_hash, std::time::Duration::ZERO);
202208
assert!(result.is_err());
203209
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
204210
}
@@ -211,16 +217,16 @@ mod tests {
211217

212218
let (dummy_key, dummy_hash) = dummy_key_and_hash();
213219
let validator =
214-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
220+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
215221

216222
// Test at boundary (512 chars - should pass)
217223
let max_key = "a".repeat(512);
218-
let result = validator.verify(&max_key, hash.as_ref());
224+
let result = validator.verify(&max_key, hash.as_ref(), std::time::Duration::ZERO);
219225
assert!(result.is_ok()); // Should not error on length check
220226

221227
// Test just over boundary (513 chars - should fail)
222228
let over_max_key = "a".repeat(513);
223-
let result = validator.verify(&over_max_key, hash.as_ref());
229+
let result = validator.verify(&over_max_key, hash.as_ref(), std::time::Duration::ZERO);
224230
assert!(result.is_err());
225231
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
226232
}
@@ -233,17 +239,17 @@ mod tests {
233239

234240
let (dummy_key, dummy_hash) = dummy_key_and_hash();
235241
let validator =
236-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
242+
KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
237243

238-
let result1 = validator.verify("wrong_key", valid_hash.as_ref());
244+
let result1 = validator.verify("wrong_key", valid_hash.as_ref(), std::time::Duration::ZERO);
239245
assert!(result1.is_ok());
240246
assert_eq!(result1.unwrap(), KeyStatus::Invalid);
241247

242-
let result2 = validator.verify(valid_key.expose_secret(), "invalid_hash_format");
248+
let result2 = validator.verify(valid_key.expose_secret(), "invalid_hash_format", std::time::Duration::ZERO);
243249
assert!(result2.is_ok());
244250
assert_eq!(result2.unwrap(), KeyStatus::Invalid);
245251

246-
let result3 = validator.verify(valid_key.expose_secret(), "not even close to valid");
252+
let result3 = validator.verify(valid_key.expose_secret(), "not even close to valid", std::time::Duration::ZERO);
247253
assert!(result3.is_ok());
248254
assert_eq!(result3.unwrap(), KeyStatus::Invalid);
249255
}

crates/api-keys-simplified/tests/checksum_dos_protection.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ fn test_checksum_prevents_expensive_verification() {
4444
fn test_valid_checksum_proceeds_to_argon2() {
4545
// Verify that valid checksums still go through Argon2 verification
4646
let config = KeyConfig::default().disable_checksum();
47-
let generator = ApiKeyManagerV0::init("verify", config, HashConfig::default()).unwrap();
47+
let generator = ApiKeyManagerV0::init("verify", config, HashConfig::default(), std::time::Duration::ZERO).unwrap();
4848

4949
let key = generator.generate(Environment::production()).unwrap();
5050

@@ -71,12 +71,13 @@ fn test_valid_checksum_proceeds_to_argon2() {
7171
fn test_dos_protection_comparison() {
7272
// Compare DoS resistance: with vs without checksum
7373
let with_checksum =
74-
ApiKeyManagerV0::init("dos1", KeyConfig::default(), HashConfig::default()).unwrap();
74+
ApiKeyManagerV0::init("dos1", KeyConfig::default(), HashConfig::default(), std::time::Duration::ZERO).unwrap();
7575

7676
let without_checksum = ApiKeyManagerV0::init(
7777
"dos2",
7878
KeyConfig::default().disable_checksum(),
7979
HashConfig::default(),
80+
std::time::Duration::ZERO,
8081
)
8182
.unwrap();
8283

@@ -124,6 +125,7 @@ fn test_without_checksum_still_works() {
124125
"nochk",
125126
KeyConfig::default().disable_checksum(),
126127
HashConfig::default(),
128+
std::time::Duration::ZERO,
127129
)
128130
.unwrap();
129131

crates/api-keys-simplified/tests/concurrency.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ fn test_concurrent_generation_and_uniqueness() {
5656
fn test_concurrent_verification_and_checksum() {
5757
// Tests: Argon2 thread safety, checksum verification (enabled by default), Arc-wrapped SecureString
5858
let config = KeyConfig::default();
59-
let generator = Arc::new(ApiKeyManagerV0::init("pk", config, HashConfig::default()).unwrap());
59+
let generator = Arc::new(ApiKeyManagerV0::init("pk", config, HashConfig::default(), std::time::Duration::ZERO).unwrap());
6060

6161
// Generate test data
6262
let mut keys_and_hashes = Vec::new();
@@ -104,12 +104,13 @@ fn test_concurrent_verification_and_checksum() {
104104
#[test]
105105
fn test_clone_safety_and_config_isolation() {
106106
// Tests: Clone safety, different configs don't interfere, cross-verification
107-
let gen1 = ApiKeyManagerV0::init("g1", KeyConfig::balanced(), HashConfig::balanced()).unwrap();
107+
let gen1 = ApiKeyManagerV0::init("g1", KeyConfig::balanced(), HashConfig::balanced(), std::time::Duration::ZERO).unwrap();
108108
let gen2_cloned = gen1.clone();
109109
let gen3 = ApiKeyManagerV0::init(
110110
"g3",
111111
KeyConfig::high_security(),
112112
HashConfig::high_security(),
113+
std::time::Duration::ZERO,
113114
)
114115
.unwrap();
115116

crates/api-keys-simplified/tests/configuration.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use api_keys_simplified::{ExposeSecret, SecureStringExt};
44
#[test]
55
fn test_custom_entropy() {
66
let config = KeyConfig::new().with_entropy(16).unwrap();
7-
let generator = ApiKeyManagerV0::init("sk", config, HashConfig::default()).unwrap();
7+
let generator = ApiKeyManagerV0::init("sk", config, HashConfig::default(), std::time::Duration::ZERO).unwrap();
88
let key = generator.generate(Environment::test()).unwrap();
99

1010
assert!(key.key().len() > 10);
@@ -13,7 +13,7 @@ fn test_custom_entropy() {
1313
#[test]
1414
fn test_without_checksum() {
1515
let config = KeyConfig::default().disable_checksum();
16-
let generator = ApiKeyManagerV0::init("pk", config, HashConfig::default()).unwrap();
16+
let generator = ApiKeyManagerV0::init("pk", config, HashConfig::default(), std::time::Duration::ZERO).unwrap();
1717
let key = generator.generate(Environment::production()).unwrap();
1818

1919
// Environment "live" means production, Base64URL can contain underscores and hyphens
@@ -54,7 +54,7 @@ fn test_custom_hash_config() {
5454
let hash_config = HashConfig::custom(8192, 1, 1).unwrap();
5555

5656
let config = KeyConfig::default();
57-
let generator = ApiKeyManagerV0::init("text", config, hash_config).unwrap();
57+
let generator = ApiKeyManagerV0::init("text", config, hash_config, std::time::Duration::ZERO).unwrap();
5858
let key = generator.generate(Environment::dev()).unwrap();
5959

6060
assert_eq!(
@@ -67,13 +67,13 @@ fn test_custom_hash_config() {
6767
fn test_entropy_boundaries() {
6868
// Minimum entropy
6969
let config_min = KeyConfig::new().with_entropy(16).unwrap();
70-
let gen_min = ApiKeyManagerV0::init("min", config_min, HashConfig::default()).unwrap();
70+
let gen_min = ApiKeyManagerV0::init("min", config_min, HashConfig::default(), std::time::Duration::ZERO).unwrap();
7171
let key_min = gen_min.generate(Environment::test()).unwrap();
7272
assert!(!key_min.key().is_empty());
7373

7474
// Maximum entropy
7575
let config_max = KeyConfig::new().with_entropy(64).unwrap();
76-
let gen_max = ApiKeyManagerV0::init("max", config_max, HashConfig::default()).unwrap();
76+
let gen_max = ApiKeyManagerV0::init("max", config_max, HashConfig::default(), std::time::Duration::ZERO).unwrap();
7777
let key_max = gen_max.generate(Environment::test()).unwrap();
7878
assert!(key_max.key().len() > key_min.key().len());
7979
}

crates/api-keys-simplified/tests/key_expiry.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ fn test_expiry_without_checksum() {
196196
use api_keys_simplified::{HashConfig, KeyConfig};
197197

198198
let config = KeyConfig::default().disable_checksum();
199-
let manager = ApiKeyManagerV0::init("sk", config, HashConfig::default()).unwrap();
199+
let manager = ApiKeyManagerV0::init("sk", config, HashConfig::default(), std::time::Duration::ZERO).unwrap();
200200

201201
let past = Utc::now() - Duration::hours(1);
202202
let future = Utc::now() + Duration::hours(1);

0 commit comments

Comments
 (0)