Skip to main content

chronix_security/auth/
encryption.rs

1//! AES-256-GCM authenticated encryption for data at rest.
2//!
3//! Provides transparent encryption/decryption of segment data blocks
4//! and WAL entries with per-entry nonces. Supports key rotation —
5//! new data uses the latest key, old data remains readable with
6//! previous keys.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use chronix_security::auth::encryption::{EncryptionService, FileKeyProvider};
12//!
13//! let provider = FileKeyProvider::from_hex_key(
14//!     "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
15//!     "key-1",
16//! ).unwrap();
17//! let service = EncryptionService::new(Box::new(provider));
18//!
19//! let plaintext = b"hello world";
20//! let (ciphertext, key_id) = service.encrypt(plaintext).unwrap();
21//! let decrypted = service.decrypt(&ciphertext, &key_id).unwrap();
22//! assert_eq!(plaintext.as_slice(), &decrypted);
23//! ```
24
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28
29use aes_gcm::aead::Aead;
30use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
31use hmac::{Hmac, Mac};
32use sha2::Sha256;
33use zeroize::Zeroize;
34
35use crate::auth::error::AuthError;
36
37/// 12-byte nonce for AES-256-GCM.
38const NONCE_SIZE: usize = 12;
39
40/// A secret key that is automatically zeroized when dropped.
41///
42/// Wraps raw key bytes to ensure they are erased from memory
43/// when the value goes out of scope, even for clones returned by
44/// [`KeyProvider::current_key`] and [`KeyProvider::key_by_id`].
45#[derive(Clone)]
46pub struct SecretKey(Vec<u8>);
47
48impl SecretKey {
49    /// Create a new `SecretKey` from raw bytes.
50    #[must_use]
51    pub fn new(bytes: Vec<u8>) -> Self {
52        Self(bytes)
53    }
54
55    /// View the key bytes.
56    #[must_use]
57    pub fn as_bytes(&self) -> &[u8] {
58        &self.0
59    }
60}
61
62impl Drop for SecretKey {
63    fn drop(&mut self) {
64        self.0.zeroize();
65    }
66}
67
68impl std::fmt::Debug for SecretKey {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "SecretKey([REDACTED; {} bytes])", self.0.len())
71    }
72}
73
74impl AsRef<[u8]> for SecretKey {
75    fn as_ref(&self) -> &[u8] {
76        &self.0
77    }
78}
79
80/// Trait for providing encryption keys.
81///
82/// Implementations can load keys from files, environment variables,
83/// or external KMS services.
84///
85/// All key material is returned as [`SecretKey`] to ensure automatic
86/// zeroization when the consumer drops the value.
87pub trait KeyProvider: Send + Sync + std::fmt::Debug {
88    /// Get the current (latest) encryption key and its ID.
89    fn current_key(&self) -> Result<(SecretKey, String), AuthError>;
90
91    /// Get a key by its ID (for decrypting old data).
92    fn key_by_id(&self, key_id: &str) -> Result<SecretKey, AuthError>;
93
94    /// List all available key IDs.
95    fn key_ids(&self) -> Vec<String>;
96}
97
98/// Key provider that reads keys from a file or config.
99///
100/// **Security:** Implements `Drop` to zeroize key material from memory.
101/// Intentionally does not implement `Clone` to prevent accidental
102/// duplication of key material that would bypass zeroization.
103pub struct FileKeyProvider {
104    keys: HashMap<String, Vec<u8>>,
105    current_key_id: String,
106}
107
108impl Drop for FileKeyProvider {
109    fn drop(&mut self) {
110        // Zeroize all key material on drop to minimize in-memory exposure.
111        // Use black_box to prevent the compiler from optimising the zeroing away.
112        for value in self.keys.values_mut() {
113            value.fill(0);
114        }
115        // Compiler fence + black_box to prevent dead-store elimination.
116        std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst);
117        std::hint::black_box(&self.keys);
118    }
119}
120
121impl std::fmt::Debug for FileKeyProvider {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("FileKeyProvider")
124            .field("current_key_id", &self.current_key_id)
125            .field("key_count", &self.keys.len())
126            .finish()
127    }
128}
129
130impl FileKeyProvider {
131    /// Create a key provider from a hex-encoded 256-bit key.
132    pub fn from_hex_key(hex_key: &str, key_id: &str) -> Result<Self, AuthError> {
133        let key = hex_decode(hex_key)?;
134        if key.len() != 32 {
135            return Err(AuthError::Config(format!(
136                "AES-256 key must be 32 bytes, got {}",
137                key.len()
138            )));
139        }
140
141        let mut keys = HashMap::new();
142        keys.insert(key_id.to_string(), key);
143
144        Ok(Self {
145            keys,
146            current_key_id: key_id.to_string(),
147        })
148    }
149
150    /// Add a key for rotation. The last added key becomes current.
151    pub fn add_key(&mut self, hex_key: &str, key_id: &str) -> Result<(), AuthError> {
152        let key = hex_decode(hex_key)?;
153        if key.len() != 32 {
154            return Err(AuthError::Config(format!(
155                "AES-256 key must be 32 bytes, got {}",
156                key.len()
157            )));
158        }
159        self.keys.insert(key_id.to_string(), key);
160        self.current_key_id = key_id.to_string();
161        Ok(())
162    }
163}
164
165impl KeyProvider for FileKeyProvider {
166    fn current_key(&self) -> Result<(SecretKey, String), AuthError> {
167        let key = self
168            .keys
169            .get(&self.current_key_id)
170            .ok_or_else(|| AuthError::KeyNotFound(self.current_key_id.clone()))?
171            .clone();
172        Ok((SecretKey::new(key), self.current_key_id.clone()))
173    }
174
175    fn key_by_id(&self, key_id: &str) -> Result<SecretKey, AuthError> {
176        self.keys
177            .get(key_id)
178            .cloned()
179            .map(SecretKey::new)
180            .ok_or_else(|| AuthError::KeyNotFound(key_id.to_string()))
181    }
182
183    fn key_ids(&self) -> Vec<String> {
184        self.keys.keys().cloned().collect()
185    }
186}
187
188/// Key provider that reads from environment variables.
189///
190/// # Design Note
191///
192/// The environment variable is read on **every call** to
193/// [`current_key()`](KeyProvider::current_key) rather than being cached at
194/// construction time. This is intentional: it allows key rotation by
195/// updating the env var (e.g. via a sidecar or init-container) without
196/// restarting the process. The overhead of `std::env::var` is negligible
197/// compared to the AES-GCM operations that follow.
198///
199/// # Cached Fallback
200///
201/// If the environment variable is temporarily unset (e.g. during key
202/// rotation), the provider falls back to the last successfully loaded
203/// key for up to [`grace_period`](EnvKeyProvider::with_grace_period)
204/// (default **5 s**). After the grace period expires, the
205/// missing-variable error is propagated.
206///
207/// # Hardening
208///
209/// * Grace period reduced from 30 s → 5 s (configurable).
210/// * [`clear_cache`](EnvKeyProvider::clear_cache) lets operators
211///   explicitly discard the cached key once rotation is complete.
212/// * Every cache-fallback hit emits the `chronix_auth_env_key_grace_hit`
213///   counter so operators can detect prolonged rotation windows.
214pub struct EnvKeyProvider {
215    env_var: String,
216    key_id: String,
217    /// Cached last-good key with the timestamp it was fetched.
218    cache: parking_lot::Mutex<Option<(SecretKey, std::time::Instant)>>,
219    /// Maximum age of a cached key after the env var disappears.
220    grace_period: std::time::Duration,
221}
222
223impl std::fmt::Debug for EnvKeyProvider {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("EnvKeyProvider")
226            .field("env_var", &self.env_var)
227            .field("key_id", &self.key_id)
228            .field("has_cached_key", &self.cache.lock().is_some())
229            .finish()
230    }
231}
232
233/// Grace period during which a cached key is returned if the env var
234/// becomes temporarily unavailable (e.g. during key rotation).
235///
236/// Reduced from 30 s → 5 s to shrink the stale-key window.
237const DEFAULT_ENV_KEY_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(5);
238
239impl EnvKeyProvider {
240    /// Create a key provider from an environment variable name.
241    ///
242    /// The environment variable should contain a hex-encoded 256-bit key.
243    /// The default grace period is 5 seconds.
244    #[must_use]
245    pub fn new(env_var: &str, key_id: &str) -> Self {
246        Self {
247            env_var: env_var.to_string(),
248            key_id: key_id.to_string(),
249            cache: parking_lot::Mutex::new(None),
250            grace_period: DEFAULT_ENV_KEY_GRACE_PERIOD,
251        }
252    }
253
254    /// Override the grace period for cached key fallback.
255    #[must_use]
256    pub fn with_grace_period(mut self, duration: std::time::Duration) -> Self {
257        self.grace_period = duration;
258        self
259    }
260
261    /// Explicitly discard the cached key, e.g. after rotation is complete.
262    pub fn clear_cache(&self) {
263        *self.cache.lock() = None;
264    }
265}
266
267impl KeyProvider for EnvKeyProvider {
268    fn current_key(&self) -> Result<(SecretKey, String), AuthError> {
269        match std::env::var(&self.env_var) {
270            Ok(hex) => {
271                let key = hex_decode(&hex)?;
272                if key.len() != 32 {
273                    return Err(AuthError::Config(format!(
274                        "AES-256 key must be 32 bytes, got {}",
275                        key.len()
276                    )));
277                }
278                let secret = SecretKey::new(key);
279                // Update the cache with the fresh key.
280                *self.cache.lock() = Some((secret.clone(), std::time::Instant::now()));
281                Ok((secret, self.key_id.clone()))
282            }
283            Err(_) => {
284                // Env var missing — fall back to cached key within grace period.
285                let guard = self.cache.lock();
286                if let Some((ref cached_key, fetched_at)) = *guard
287                    && fetched_at.elapsed() < self.grace_period
288                {
289                    tracing::warn!(
290                        env_var = %self.env_var,
291                        grace_remaining_secs = (self.grace_period.saturating_sub(fetched_at.elapsed())).as_secs(),
292                        "env var missing, using cached key within grace period"
293                    );
294                    metrics::counter!("chronix_auth_env_key_grace_hit").increment(1);
295                    return Ok((cached_key.clone(), self.key_id.clone()));
296                }
297                drop(guard);
298                Err(AuthError::Config(format!(
299                    "env var {} not set",
300                    self.env_var
301                )))
302            }
303        }
304    }
305
306    fn key_by_id(&self, key_id: &str) -> Result<SecretKey, AuthError> {
307        if key_id == self.key_id {
308            self.current_key().map(|(k, _)| k)
309        } else {
310            Err(AuthError::KeyNotFound(key_id.to_string()))
311        }
312    }
313
314    fn key_ids(&self) -> Vec<String> {
315        vec![self.key_id.clone()]
316    }
317}
318
319/// AES-256-GCM encryption service for data at rest.
320///
321/// Encrypted format: `[12-byte nonce][ciphertext + 16-byte tag]`
322///
323/// Tracks invocation count per NIST SP 800-38D recommendation to
324/// rotate keys before 2^32 encryptions with random nonces.
325#[derive(Debug)]
326pub struct EncryptionService {
327    provider: Box<dyn KeyProvider>,
328    /// Number of `encrypt()` calls since creation. NIST recommends
329    /// key rotation before 2^32 encryptions with random nonces.
330    invocation_count: Arc<AtomicU64>,
331}
332
333/// NIST SP 800-38D recommended maximum encryptions per key with random nonces.
334const NIST_KEY_USAGE_LIMIT: u64 = 1 << 32;
335
336impl EncryptionService {
337    /// Create a new encryption service with the given key provider.
338    #[must_use]
339    pub fn new(provider: Box<dyn KeyProvider>) -> Self {
340        Self {
341            provider,
342            invocation_count: Arc::new(AtomicU64::new(0)),
343        }
344    }
345
346    /// Returns the number of `encrypt()` calls since creation.
347    #[must_use]
348    pub fn invocation_count(&self) -> u64 {
349        self.invocation_count.load(Ordering::Relaxed)
350    }
351
352    /// Derive a domain-separated subkey from the master key
353    /// using HMAC-SHA256 as a PRF (per NIST SP 800-108 counter mode KDF).
354    /// This ensures the AES-256-GCM encryption key and HMAC-SHA256
355    /// integrity key are cryptographically independent.
356    ///
357    /// Returns a [`SecretKey`] so the derived key is automatically zeroized.
358    fn derive_subkey(master: &[u8], domain: &[u8]) -> SecretKey {
359        let mut mac =
360            <Hmac<Sha256> as KeyInit>::new_from_slice(master).expect("HMAC accepts any key size");
361        mac.update(domain);
362        SecretKey::new(mac.finalize().into_bytes().to_vec())
363    }
364
365    /// Encrypt data using the current key.
366    ///
367    /// Returns `(ciphertext_with_nonce, key_id)`.
368    /// Format: `[12-byte nonce][AES-256-GCM ciphertext + 16-byte auth tag]`
369    pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, String), AuthError> {
370        let count = self.invocation_count.fetch_add(1, Ordering::Relaxed);
371        // Enforce NIST SP 800-38D limit — reject encryption past 2^32
372        // invocations to prevent nonce collision risk with random nonces.
373        if count >= NIST_KEY_USAGE_LIMIT {
374            return Err(AuthError::Encryption(format!(
375                "AES-GCM key usage limit reached ({count} encryptions); \
376                 rotate key to avoid nonce collision risk (NIST SP 800-38D)"
377            )));
378        }
379        if count == NIST_KEY_USAGE_LIMIT - 1 {
380            tracing::warn!(
381                count = count + 1,
382                "AES-GCM key approaching 2^32 encryption limit; rotate key soon"
383            );
384        }
385
386        let (master_key, key_id) = self.provider.current_key()?;
387        let enc_key = Self::derive_subkey(master_key.as_bytes(), b"chronix-aes-gcm-enc-v1");
388
389        let cipher = Aes256Gcm::new_from_slice(enc_key.as_bytes())
390            .map_err(|e| AuthError::Encryption(format!("key error: {e}")))?;
391
392        // Generate random nonce
393        let nonce_bytes = generate_nonce();
394        let nonce = Nonce::try_from(&nonce_bytes[..])
395            .map_err(|_| AuthError::Config("nonce must be 12 bytes".into()))?;
396
397        let ciphertext = cipher
398            .encrypt(&nonce, plaintext)
399            .map_err(|e| AuthError::Encryption(e.to_string()))?;
400
401        // Prepend nonce to ciphertext
402        let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
403        result.extend_from_slice(&nonce_bytes);
404        result.extend_from_slice(&ciphertext);
405
406        Ok((result, key_id))
407    }
408
409    /// Decrypt data using the specified key.
410    ///
411    /// Input format: `[12-byte nonce][AES-256-GCM ciphertext + 16-byte auth tag]`
412    pub fn decrypt(&self, data: &[u8], key_id: &str) -> Result<Vec<u8>, AuthError> {
413        if data.len() < NONCE_SIZE {
414            return Err(AuthError::Decryption("data too short for nonce".into()));
415        }
416
417        let master_key = self.provider.key_by_id(key_id)?;
418        let enc_key = Self::derive_subkey(master_key.as_bytes(), b"chronix-aes-gcm-enc-v1");
419
420        let cipher = Aes256Gcm::new_from_slice(enc_key.as_bytes())
421            .map_err(|e| AuthError::Decryption(format!("key error: {e}")))?;
422
423        let nonce = Nonce::try_from(&data[..NONCE_SIZE])
424            .map_err(|_| AuthError::Config("nonce must be 12 bytes".into()))?;
425        let ciphertext = &data[NONCE_SIZE..];
426
427        cipher
428            .decrypt(&nonce, ciphertext)
429            .map_err(|e| AuthError::Decryption(e.to_string()))
430    }
431
432    /// Compute HMAC-SHA256 over data for integrity verification.
433    pub fn hmac(&self, data: &[u8]) -> Result<Vec<u8>, AuthError> {
434        let (master_key, _) = self.provider.current_key()?;
435        let hmac_key = Self::derive_subkey(master_key.as_bytes(), b"chronix-hmac-sha256-v1");
436
437        let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(hmac_key.as_bytes())
438            .map_err(|e| AuthError::Encryption(format!("HMAC error: {e}")))?;
439        mac.update(data);
440        Ok(mac.finalize().into_bytes().to_vec())
441    }
442
443    /// Verify HMAC-SHA256 integrity.
444    pub fn verify_hmac(
445        &self,
446        data: &[u8],
447        expected: &[u8],
448        key_id: &str,
449    ) -> Result<bool, AuthError> {
450        let master_key = self.provider.key_by_id(key_id)?;
451        let hmac_key = Self::derive_subkey(master_key.as_bytes(), b"chronix-hmac-sha256-v1");
452
453        let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(hmac_key.as_bytes())
454            .map_err(|e| AuthError::Encryption(format!("HMAC error: {e}")))?;
455        mac.update(data);
456
457        Ok(mac.verify_slice(expected).is_ok())
458    }
459
460    /// Get the current key ID.
461    pub fn current_key_id(&self) -> Result<String, AuthError> {
462        self.provider.current_key().map(|(_, id)| id)
463    }
464
465    /// List all available key IDs.
466    #[must_use]
467    pub fn key_ids(&self) -> Vec<String> {
468        self.provider.key_ids()
469    }
470}
471
472/// Generate a random 12-byte nonce.
473fn generate_nonce() -> [u8; NONCE_SIZE] {
474    let mut nonce = [0u8; NONCE_SIZE];
475    super::fill_random(&mut nonce);
476    nonce
477}
478
479/// Decode a hex string to bytes.
480fn hex_decode(hex: &str) -> Result<Vec<u8>, AuthError> {
481    if !hex.len().is_multiple_of(2) {
482        return Err(AuthError::Config("hex string must have even length".into()));
483    }
484
485    (0..hex.len())
486        .step_by(2)
487        .map(|i| {
488            u8::from_str_radix(&hex[i..i + 2], 16)
489                .map_err(|e| AuthError::Config(format!("invalid hex: {e}")))
490        })
491        .collect()
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    const TEST_KEY_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
499
500    fn test_service() -> EncryptionService {
501        let provider = FileKeyProvider::from_hex_key(TEST_KEY_HEX, "key-1").unwrap();
502        EncryptionService::new(Box::new(provider))
503    }
504
505    #[test]
506    fn encrypt_decrypt_roundtrip() {
507        let svc = test_service();
508        let plaintext = b"The quick brown fox jumps over the lazy dog";
509
510        let (ciphertext, key_id) = svc.encrypt(plaintext).unwrap();
511        assert_ne!(ciphertext.as_slice(), plaintext.as_slice());
512        assert_eq!(key_id, "key-1");
513
514        let decrypted = svc.decrypt(&ciphertext, &key_id).unwrap();
515        assert_eq!(decrypted, plaintext);
516    }
517
518    #[test]
519    fn empty_plaintext_roundtrip() {
520        let svc = test_service();
521        let plaintext = b"";
522
523        let (ciphertext, key_id) = svc.encrypt(plaintext).unwrap();
524        let decrypted = svc.decrypt(&ciphertext, &key_id).unwrap();
525        assert_eq!(decrypted, plaintext);
526    }
527
528    #[test]
529    fn large_data_roundtrip() {
530        let svc = test_service();
531        let plaintext: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect();
532
533        let (ciphertext, key_id) = svc.encrypt(&plaintext).unwrap();
534        let decrypted = svc.decrypt(&ciphertext, &key_id).unwrap();
535        assert_eq!(decrypted, plaintext);
536    }
537
538    #[test]
539    fn tampered_data_fails() {
540        let svc = test_service();
541        let plaintext = b"sensitive data";
542
543        let (mut ciphertext, key_id) = svc.encrypt(plaintext).unwrap();
544        // Flip a bit in the ciphertext (after nonce)
545        if let Some(byte) = ciphertext.get_mut(NONCE_SIZE + 1) {
546            *byte ^= 0xFF;
547        }
548
549        let err = svc.decrypt(&ciphertext, &key_id).unwrap_err();
550        assert!(matches!(err, AuthError::Decryption(_)));
551    }
552
553    #[test]
554    fn wrong_key_fails() {
555        let svc = test_service();
556        let plaintext = b"test data";
557
558        let (ciphertext, _) = svc.encrypt(plaintext).unwrap();
559
560        // Try to decrypt with wrong key ID
561        let err = svc.decrypt(&ciphertext, "nonexistent-key").unwrap_err();
562        assert!(matches!(err, AuthError::KeyNotFound(_)));
563    }
564
565    #[test]
566    fn key_rotation() {
567        let provider1 = FileKeyProvider::from_hex_key(TEST_KEY_HEX, "key-1").unwrap();
568
569        // Encrypt with key-1
570        let svc1 = EncryptionService::new(Box::new(provider1));
571        let plaintext = b"data with key 1";
572        let (ct1, kid1) = svc1.encrypt(plaintext).unwrap();
573        assert_eq!(kid1, "key-1");
574
575        // Create a fresh provider with key-1, then add key-2 (becomes current)
576        let mut provider2 = FileKeyProvider::from_hex_key(TEST_KEY_HEX, "key-1").unwrap();
577        let key2_hex = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
578        provider2.add_key(key2_hex, "key-2").unwrap();
579
580        let svc2 = EncryptionService::new(Box::new(provider2));
581
582        // New encryption uses key-2
583        let (ct2, kid2) = svc2.encrypt(b"data with key 2").unwrap();
584        assert_eq!(kid2, "key-2");
585
586        // Can still decrypt old data with key-1
587        let dec1 = svc2.decrypt(&ct1, "key-1").unwrap();
588        assert_eq!(dec1, plaintext);
589
590        // Can decrypt new data with key-2
591        let dec2 = svc2.decrypt(&ct2, "key-2").unwrap();
592        assert_eq!(dec2, b"data with key 2");
593    }
594
595    #[test]
596    fn hmac_verify() {
597        let svc = test_service();
598        let data = b"manifest data to verify";
599
600        let mac = svc.hmac(data).unwrap();
601        assert!(svc.verify_hmac(data, &mac, "key-1").unwrap());
602
603        // Tampered data
604        let mut tampered = data.to_vec();
605        tampered[0] ^= 0xFF;
606        assert!(!svc.verify_hmac(&tampered, &mac, "key-1").unwrap());
607    }
608
609    #[test]
610    fn hmac_tampered_mac_fails() {
611        let svc = test_service();
612        let data = b"important data";
613
614        let mut mac = svc.hmac(data).unwrap();
615        mac[0] ^= 0xFF; // tamper with MAC
616        assert!(!svc.verify_hmac(data, &mac, "key-1").unwrap());
617    }
618
619    #[test]
620    fn short_key_rejected() {
621        let result = FileKeyProvider::from_hex_key("0123456789abcdef", "k");
622        assert!(result.is_err());
623    }
624
625    #[test]
626    fn invalid_hex_rejected() {
627        let result = FileKeyProvider::from_hex_key("zzzz", "k");
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn data_too_short_for_nonce() {
633        let svc = test_service();
634        let err = svc.decrypt(&[0u8; 5], "key-1").unwrap_err();
635        assert!(matches!(err, AuthError::Decryption(_)));
636    }
637
638    #[test]
639    fn hex_decode_works() {
640        assert_eq!(hex_decode("48656c6c6f").unwrap(), b"Hello");
641        assert_eq!(hex_decode("").unwrap(), b"");
642        assert!(hex_decode("0").is_err()); // odd length
643    }
644
645    // ── Hardening tests ─────────────────────────
646
647    #[test]
648    fn env_key_default_grace_period_is_5s() {
649        let provider = EnvKeyProvider::new("CHRONIX_TEST_KEY_NOT_SET", "k1");
650        assert_eq!(provider.grace_period, std::time::Duration::from_secs(5));
651    }
652
653    #[test]
654    fn env_key_custom_grace_period() {
655        let provider = EnvKeyProvider::new("CHRONIX_TEST_KEY_NOT_SET", "k1")
656            .with_grace_period(std::time::Duration::from_secs(2));
657        assert_eq!(provider.grace_period, std::time::Duration::from_secs(2));
658    }
659
660    #[test]
661    fn env_key_clear_cache_discards_key() {
662        // Manually seed the cache
663        let provider = EnvKeyProvider::new("CHRONIX_NEVER_SET_12345", "k1");
664        {
665            let key_bytes = vec![0u8; 32];
666            *provider.cache.lock() = Some((SecretKey::new(key_bytes), std::time::Instant::now()));
667        }
668
669        // Cache is populated → current_key would succeed via grace fallback
670        assert!(provider.current_key().is_ok());
671
672        // clear_cache → must fail now
673        provider.clear_cache();
674        assert!(provider.current_key().is_err());
675    }
676
677    #[test]
678    fn env_key_expired_grace_returns_error() {
679        let provider = EnvKeyProvider::new("CHRONIX_NEVER_SET_67890", "k1")
680            .with_grace_period(std::time::Duration::from_millis(0));
681
682        // Seed cache with an "instant now" key
683        {
684            let key_bytes = vec![0u8; 32];
685            *provider.cache.lock() = Some((SecretKey::new(key_bytes), std::time::Instant::now()));
686        }
687
688        // 0 ms grace → already expired
689        assert!(provider.current_key().is_err());
690    }
691}