Skip to content

Commit d4a7d04

Browse files
fix: key expiration logic (#20)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4db6c9d commit d4a7d04

File tree

10 files changed

+262
-70
lines changed

10 files changed

+262
-70
lines changed

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

Lines changed: 39 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,35 @@ 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(
82+
prefix,
83+
KeyConfig::default(),
84+
HashConfig::default(),
85+
std::time::Duration::from_secs(10),
86+
)
7887
}
7988
pub fn init_high_security_config(
8089
prefix: impl Into<String>,
@@ -83,6 +92,7 @@ impl ApiKeyManagerV0 {
8392
prefix,
8493
KeyConfig::high_security(),
8594
HashConfig::high_security(),
95+
std::time::Duration::from_secs(10),
8696
)
8797
}
8898

@@ -143,11 +153,23 @@ impl ApiKeyManagerV0 {
143153
///
144154
/// Returns `KeyStatus` indicating whether the key is valid or invalid.
145155
///
156+
/// # Parameters
157+
///
158+
/// * `key` - The API key to verify
159+
/// * `stored_hash` - The Argon2 hash stored in your database
160+
/// * `expiry_grace_period` - Optional grace period duration after expiry.
161+
/// - `None`: Skip expiry validation (all keys treated as non-expired)
162+
/// - `Some(Duration::ZERO)`: Strict expiry check (no grace period)
163+
/// - `Some(duration)`: Key remains valid for `duration` after its expiry time
164+
///
165+
/// The grace period protects against clock skew issues. Once a key expires beyond
166+
/// the grace period, it stays expired even if the system clock goes backwards.
167+
///
146168
/// # Security Flow
147169
///
148170
/// 1. **Checksum validation** (if enabled): Rejects invalid keys in ~20μs
149171
/// 2. **Argon2 verification**: Verifies hash for valid checksums (~300ms)
150-
/// 3. **Expiry check**: Returns `Invalid` if the key's timestamp has passed
172+
/// 3. **Expiry check**: Returns `Invalid` if expired beyond grace period
151173
///
152174
/// # Returns
153175
///
@@ -165,6 +187,7 @@ impl ApiKeyManagerV0 {
165187
///
166188
/// ```rust
167189
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, KeyStatus};
190+
/// # use std::time::Duration;
168191
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
169192
/// # let key = manager.generate(Environment::production()).unwrap();
170193
/// match manager.verify(key.key(), key.hash())? {
@@ -178,8 +201,11 @@ impl ApiKeyManagerV0 {
178201
return Ok(KeyStatus::Invalid);
179202
}
180203

181-
self.validator
182-
.verify(key.expose_secret(), stored_hash.as_ref())
204+
self.validator.verify(
205+
key.expose_secret(),
206+
stored_hash.as_ref(),
207+
self.expiry_grace_period,
208+
)
183209
}
184210

185211
pub fn verify_checksum(&self, key: &SecureString) -> Result<bool> {
@@ -282,7 +308,13 @@ mod tests {
282308
fn test_custom_config() {
283309
let config = KeyConfig::new().with_entropy(32).unwrap();
284310

285-
let generator = ApiKeyManagerV0::init("custom", config, HashConfig::default()).unwrap();
311+
let generator = ApiKeyManagerV0::init(
312+
"custom",
313+
config,
314+
HashConfig::default(),
315+
std::time::Duration::ZERO,
316+
)
317+
.unwrap();
286318
let key = generator.generate(Environment::production()).unwrap();
287319
assert!(generator.verify_checksum(key.key()).unwrap());
288320
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,13 @@ mod tests {
373373

374374
#[test]
375375
fn test_verify_checksum_dos_protection() {
376-
let generator =
377-
ApiKeyManagerV0::init("sk", KeyConfig::balanced(), HashConfig::default()).unwrap();
376+
let generator = ApiKeyManagerV0::init(
377+
"sk",
378+
KeyConfig::balanced(),
379+
HashConfig::default(),
380+
std::time::Duration::ZERO,
381+
)
382+
.unwrap();
378383

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

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

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

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

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

Lines changed: 54 additions & 35 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,39 @@ impl KeyValidator {
4847
})
4948
}
5049

51-
fn verify_expiry(&self, parts: Parts) -> Result<KeyStatus> {
50+
fn verify_expiry(
51+
&self,
52+
parts: Parts,
53+
expiry_grace_period: std::time::Duration,
54+
) -> Result<KeyStatus> {
5255
if let Some(expiry) = parts.expiry_b64 {
5356
let decoded = URL_SAFE_NO_PAD
5457
.decode(expiry)
5558
.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)
59+
let expiry_timestamp =
60+
i64::from_be_bytes(decoded.try_into().or(Err(Error::InvalidFormat))?);
61+
62+
let current_time = chrono::Utc::now().timestamp();
63+
let grace_seconds = expiry_grace_period.as_secs() as i64;
64+
65+
// Key is invalid if it expired more than grace_period ago
66+
// This ensures once a key expires beyond the grace period, it stays expired
67+
// even if the clock goes backwards
68+
if expiry_timestamp + grace_seconds < current_time {
69+
return Ok(KeyStatus::Invalid);
6570
}
71+
Ok(KeyStatus::Valid)
6672
} else {
6773
Ok(KeyStatus::Valid)
6874
}
6975
}
7076

71-
pub fn verify(&self, provided_key: &str, stored_hash: &str) -> Result<KeyStatus> {
77+
pub fn verify(
78+
&self,
79+
provided_key: &str,
80+
stored_hash: &str,
81+
expiry_grace_period: std::time::Duration,
82+
) -> Result<KeyStatus> {
7283
// Input length validation to prevent DoS attacks
7384
if provided_key.len() > Self::MAX_KEY_LENGTH {
7485
self.dummy_load();
@@ -109,7 +120,7 @@ impl KeyValidator {
109120
// SECURITY: Force evaluation of expiry check BEFORE the match to ensure
110121
// constant-time execution. This prevents the compiler from short-circuiting
111122
// the expiry check when argon_result is Invalid, which would create a timing oracle.
112-
let expiry_result = self.verify_expiry(token_parts)?;
123+
let expiry_result = self.verify_expiry(token_parts, expiry_grace_period)?;
113124

114125
match (argon_result, expiry_result) {
115126
(KeyStatus::Invalid, _) | (_, KeyStatus::Invalid) => Ok(KeyStatus::Invalid),
@@ -150,26 +161,30 @@ mod tests {
150161
let hash = hasher.hash(&key).unwrap();
151162

152163
let (dummy_key, dummy_hash) = dummy_key_and_hash();
153-
let validator =
154-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
164+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
155165
assert_eq!(
156166
validator
157-
.verify(key.expose_secret(), hash.as_ref())
167+
.verify(
168+
key.expose_secret(),
169+
hash.as_ref(),
170+
std::time::Duration::ZERO
171+
)
158172
.unwrap(),
159173
KeyStatus::Valid
160174
);
161175
assert_eq!(
162-
validator.verify("wrong_key", hash.as_ref()).unwrap(),
176+
validator
177+
.verify("wrong_key", hash.as_ref(), std::time::Duration::ZERO)
178+
.unwrap(),
163179
KeyStatus::Invalid
164180
);
165181
}
166182

167183
#[test]
168184
fn test_invalid_hash_format() {
169185
let (dummy_key, dummy_hash) = dummy_key_and_hash();
170-
let validator =
171-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
172-
let result = validator.verify("any_key", "invalid_hash");
186+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
187+
let result = validator.verify("any_key", "invalid_hash", std::time::Duration::ZERO);
173188
// After timing oracle fix: invalid hash format returns Ok(Invalid) instead of Err
174189
// to prevent timing-based user enumeration attacks
175190
assert!(result.is_ok());
@@ -184,9 +199,8 @@ mod tests {
184199
let hash = hasher.hash(&valid_key).unwrap();
185200

186201
let (dummy_key, dummy_hash) = dummy_key_and_hash();
187-
let validator =
188-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
189-
let result = validator.verify(&oversized_key, hash.as_ref());
202+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
203+
let result = validator.verify(&oversized_key, hash.as_ref(), std::time::Duration::ZERO);
190204
assert!(result.is_err());
191205
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
192206
}
@@ -196,9 +210,8 @@ mod tests {
196210
let oversized_hash = "a".repeat(513); // Exceeds MAX_HASH_LENGTH
197211

198212
let (dummy_key, dummy_hash) = dummy_key_and_hash();
199-
let validator =
200-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
201-
let result = validator.verify("valid_key", &oversized_hash);
213+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
214+
let result = validator.verify("valid_key", &oversized_hash, std::time::Duration::ZERO);
202215
assert!(result.is_err());
203216
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
204217
}
@@ -210,17 +223,16 @@ mod tests {
210223
let hash = hasher.hash(&valid_key).unwrap();
211224

212225
let (dummy_key, dummy_hash) = dummy_key_and_hash();
213-
let validator =
214-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
226+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
215227

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

221233
// Test just over boundary (513 chars - should fail)
222234
let over_max_key = "a".repeat(513);
223-
let result = validator.verify(&over_max_key, hash.as_ref());
235+
let result = validator.verify(&over_max_key, hash.as_ref(), std::time::Duration::ZERO);
224236
assert!(result.is_err());
225237
assert!(matches!(result.unwrap_err(), Error::InvalidFormat));
226238
}
@@ -232,18 +244,25 @@ mod tests {
232244
let valid_hash = hasher.hash(&valid_key).unwrap();
233245

234246
let (dummy_key, dummy_hash) = dummy_key_and_hash();
235-
let validator =
236-
KeyValidator::new(&HashConfig::default(), true, dummy_key, dummy_hash).unwrap();
247+
let validator = KeyValidator::new(true, dummy_key, dummy_hash).unwrap();
237248

238-
let result1 = validator.verify("wrong_key", valid_hash.as_ref());
249+
let result1 = validator.verify("wrong_key", valid_hash.as_ref(), std::time::Duration::ZERO);
239250
assert!(result1.is_ok());
240251
assert_eq!(result1.unwrap(), KeyStatus::Invalid);
241252

242-
let result2 = validator.verify(valid_key.expose_secret(), "invalid_hash_format");
253+
let result2 = validator.verify(
254+
valid_key.expose_secret(),
255+
"invalid_hash_format",
256+
std::time::Duration::ZERO,
257+
);
243258
assert!(result2.is_ok());
244259
assert_eq!(result2.unwrap(), KeyStatus::Invalid);
245260

246-
let result3 = validator.verify(valid_key.expose_secret(), "not even close to valid");
261+
let result3 = validator.verify(
262+
valid_key.expose_secret(),
263+
"not even close to valid",
264+
std::time::Duration::ZERO,
265+
);
247266
assert!(result3.is_ok());
248267
assert_eq!(result3.unwrap(), KeyStatus::Invalid);
249268
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ 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(
48+
"verify",
49+
config,
50+
HashConfig::default(),
51+
std::time::Duration::ZERO,
52+
)
53+
.unwrap();
4854

4955
let key = generator.generate(Environment::production()).unwrap();
5056

@@ -70,13 +76,19 @@ fn test_valid_checksum_proceeds_to_argon2() {
7076
#[test]
7177
fn test_dos_protection_comparison() {
7278
// Compare DoS resistance: with vs without checksum
73-
let with_checksum =
74-
ApiKeyManagerV0::init("dos1", KeyConfig::default(), HashConfig::default()).unwrap();
79+
let with_checksum = ApiKeyManagerV0::init(
80+
"dos1",
81+
KeyConfig::default(),
82+
HashConfig::default(),
83+
std::time::Duration::ZERO,
84+
)
85+
.unwrap();
7586

7687
let without_checksum = ApiKeyManagerV0::init(
7788
"dos2",
7889
KeyConfig::default().disable_checksum(),
7990
HashConfig::default(),
91+
std::time::Duration::ZERO,
8092
)
8193
.unwrap();
8294

@@ -124,6 +136,7 @@ fn test_without_checksum_still_works() {
124136
"nochk",
125137
KeyConfig::default().disable_checksum(),
126138
HashConfig::default(),
139+
std::time::Duration::ZERO,
127140
)
128141
.unwrap();
129142

0 commit comments

Comments
 (0)