Skip to main content

chronix_security/auth/
rotation.rs

1//! Automated secret rotation for encryption keys.
2//!
3//! Provides a [`RotatingKeyProvider`] that wraps any [`KeyProvider`] and
4//! supports automatic key generation and rotation based on configurable
5//! policies (time-based, usage-based, or both).
6//!
7//! # Architecture
8//!
9//! ```text
10//! RotatingKeyProvider
11//! ├── inner: Arc<RwLock<RotatingState>>
12//! │   ├── keys: BTreeMap<String, SecretKey>
13//! │   ├── current_key_id: String
14//! │   ├── created_at: HashMap<String, Instant>
15//! │   └── usage_counts: HashMap<String, u64>
16//! ├── policy: RotationPolicy
17//! └── audit: RotationAuditLog
18//! ```
19//!
20//! # Example
21//!
22//! ```no_run
23//! use chronix_security::auth::rotation::{RotatingKeyProvider, RotationPolicy};
24//! use std::time::Duration;
25//!
26//! let policy = RotationPolicy::builder()
27//!     .max_age(Duration::from_secs(86400))     // rotate every 24h
28//!     .max_encryptions(1_000_000)               // or after 1M encryptions
29//!     .retain_old_keys(5)                        // keep 5 old keys for decryption
30//!     .build();
31//!
32//! let provider = RotatingKeyProvider::new(policy);
33//! // provider implements KeyProvider — pass to EncryptionService
34//! ```
35
36use std::collections::{BTreeMap, VecDeque};
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::time::{Duration, Instant};
39
40use parking_lot::RwLock;
41use zeroize::Zeroize;
42
43use crate::auth::encryption::{KeyProvider, SecretKey};
44use crate::auth::error::AuthError;
45
46/// Policy governing when automatic key rotation occurs.
47#[derive(Debug, Clone)]
48pub struct RotationPolicy {
49    /// Maximum age of a key before rotation is triggered.
50    /// `None` disables time-based rotation.
51    pub max_age: Option<Duration>,
52
53    /// Maximum number of encryptions before rotation is triggered.
54    /// `None` disables usage-based rotation.
55    pub max_encryptions: Option<u64>,
56
57    /// Number of old keys to retain for decrypting historical data.
58    /// Oldest keys beyond this limit are purged.
59    pub retain_old_keys: usize,
60}
61
62impl Default for RotationPolicy {
63    fn default() -> Self {
64        Self {
65            max_age: Some(Duration::from_secs(24 * 60 * 60)), // 24 hours
66            max_encryptions: Some(1 << 31),                   // 2^31 (~2B)
67            retain_old_keys: 5,
68        }
69    }
70}
71
72impl RotationPolicy {
73    /// Create a builder for `RotationPolicy`.
74    #[must_use]
75    pub fn builder() -> RotationPolicyBuilder {
76        RotationPolicyBuilder::default()
77    }
78}
79
80/// Builder for [`RotationPolicy`].
81#[derive(Debug, Default)]
82pub struct RotationPolicyBuilder {
83    max_age: Option<Duration>,
84    max_encryptions: Option<u64>,
85    retain_old_keys: Option<usize>,
86}
87
88impl RotationPolicyBuilder {
89    /// Set the maximum age before rotation.
90    #[must_use]
91    pub fn max_age(mut self, age: Duration) -> Self {
92        self.max_age = Some(age);
93        self
94    }
95
96    /// Set the maximum encryption count before rotation.
97    #[must_use]
98    pub fn max_encryptions(mut self, count: u64) -> Self {
99        self.max_encryptions = Some(count);
100        self
101    }
102
103    /// Set how many old keys to retain for decryption.
104    #[must_use]
105    pub fn retain_old_keys(mut self, count: usize) -> Self {
106        self.retain_old_keys = Some(count);
107        self
108    }
109
110    /// Build the policy.
111    #[must_use]
112    pub fn build(self) -> RotationPolicy {
113        RotationPolicy {
114            max_age: self.max_age,
115            max_encryptions: self.max_encryptions,
116            retain_old_keys: self.retain_old_keys.unwrap_or(5),
117        }
118    }
119}
120
121/// Record of a single key rotation event.
122#[derive(Debug, Clone)]
123pub struct RotationEvent {
124    /// ID of the old (retired) key.
125    pub old_key_id: String,
126    /// ID of the new (active) key.
127    pub new_key_id: String,
128    /// Reason for rotation.
129    pub reason: RotationReason,
130    /// Timestamp of the rotation (monotonic).
131    pub rotated_at: Instant,
132    /// Number of encryptions performed with the old key.
133    pub old_key_encryptions: u64,
134}
135
136/// Why a key rotation was triggered.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RotationReason {
139    /// Key exceeded maximum age.
140    MaxAge,
141    /// Key exceeded maximum encryption count.
142    MaxEncryptions,
143    /// Manual rotation requested.
144    Manual,
145}
146
147impl std::fmt::Display for RotationReason {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        match self {
150            Self::MaxAge => write!(f, "max_age"),
151            Self::MaxEncryptions => write!(f, "max_encryptions"),
152            Self::Manual => write!(f, "manual"),
153        }
154    }
155}
156
157/// Internal state for key rotation.
158struct RotatingState {
159    /// All keys indexed by ID (current + old).
160    keys: BTreeMap<String, Vec<u8>>,
161    /// The current (active) key ID.
162    current_key_id: String,
163    /// When each key was created.
164    created_at: BTreeMap<String, Instant>,
165    /// Monotonically increasing key sequence number.
166    key_sequence: u64,
167}
168
169impl Drop for RotatingState {
170    fn drop(&mut self) {
171        for value in self.keys.values_mut() {
172            value.zeroize();
173        }
174        std::sync::atomic::fence(Ordering::SeqCst);
175        std::hint::black_box(&self.keys);
176    }
177}
178
179/// A key provider with automatic rotation support.
180///
181/// Generates cryptographically random AES-256 keys and rotates them
182/// based on the configured [`RotationPolicy`]. Old keys are retained
183/// (up to `retain_old_keys`) so historical data can still be decrypted.
184///
185/// Thread-safe: uses `RwLock` for state and `AtomicU64` for the
186/// encryption counter (zero contention on the hot read path).
187pub struct RotatingKeyProvider {
188    state: RwLock<RotatingState>,
189    policy: RotationPolicy,
190    /// Encryption count for the current key (atomic for hot-path performance).
191    current_usage: AtomicU64,
192    /// Audit log of rotation events (bounded ring buffer).
193    events: RwLock<VecDeque<RotationEvent>>,
194    /// Maximum number of audit events to retain.
195    max_events: usize,
196}
197
198impl std::fmt::Debug for RotatingKeyProvider {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        let state = self.state.read();
201        f.debug_struct("RotatingKeyProvider")
202            .field("current_key_id", &state.current_key_id)
203            .field("key_count", &state.keys.len())
204            .field("current_usage", &self.current_usage.load(Ordering::Relaxed))
205            .field("policy", &self.policy)
206            .field("event_count", &self.events.read().len())
207            .finish()
208    }
209}
210
211/// Generate a cryptographically random 256-bit key.
212fn generate_random_key() -> Vec<u8> {
213    let mut key = vec![0u8; 32];
214    super::fill_random(&mut key);
215    key
216}
217
218/// Format a key ID from a sequence number.
219fn format_key_id(sequence: u64) -> String {
220    format!("rk-{sequence}")
221}
222
223impl RotatingKeyProvider {
224    /// Create a new rotating key provider with the given policy.
225    ///
226    /// An initial random key is generated immediately.
227    #[must_use]
228    pub fn new(policy: RotationPolicy) -> Self {
229        let key = generate_random_key();
230        let key_id = format_key_id(1);
231        let now = Instant::now();
232
233        let mut keys = BTreeMap::new();
234        keys.insert(key_id.clone(), key);
235
236        let mut created_at = BTreeMap::new();
237        created_at.insert(key_id.clone(), now);
238
239        let state = RotatingState {
240            keys,
241            current_key_id: key_id,
242            created_at,
243            key_sequence: 1,
244        };
245
246        Self {
247            state: RwLock::new(state),
248            policy,
249            current_usage: AtomicU64::new(0),
250            events: RwLock::new(VecDeque::new()),
251            max_events: 1000,
252        }
253    }
254
255    /// Create a rotating provider seeded with an existing key.
256    ///
257    /// Useful for migrating from a static key to rotating keys.
258    pub fn with_initial_key(
259        key_bytes: Vec<u8>,
260        key_id: &str,
261        policy: RotationPolicy,
262    ) -> Result<Self, AuthError> {
263        if key_bytes.len() != 32 {
264            return Err(AuthError::Config(format!(
265                "AES-256 key must be 32 bytes, got {}",
266                key_bytes.len()
267            )));
268        }
269
270        let now = Instant::now();
271        let mut keys = BTreeMap::new();
272        keys.insert(key_id.to_string(), key_bytes);
273
274        let mut created_at = BTreeMap::new();
275        created_at.insert(key_id.to_string(), now);
276
277        let state = RotatingState {
278            keys,
279            current_key_id: key_id.to_string(),
280            created_at,
281            key_sequence: 1,
282        };
283
284        Ok(Self {
285            state: RwLock::new(state),
286            policy,
287            current_usage: AtomicU64::new(0),
288            events: RwLock::new(VecDeque::new()),
289            max_events: 1000,
290        })
291    }
292
293    /// Check if the current key needs rotation and rotate if necessary.
294    ///
295    /// Returns `Some(event)` if rotation occurred, `None` otherwise.
296    /// This is called automatically on each `current_key()` call.
297    fn maybe_rotate(&self) -> Option<RotationEvent> {
298        let usage = self.current_usage.load(Ordering::Relaxed);
299        let state = self.state.read();
300
301        let reason = self.check_rotation_needed(&state, usage);
302        let reason = reason?;
303
304        // Drop read lock before upgrading to write
305        let old_key_id = state.current_key_id.clone();
306        drop(state);
307
308        let mut state = self.state.write();
309        // Double-check: another thread may have rotated between our read and write
310        if state.current_key_id != old_key_id {
311            return None;
312        }
313
314        let old_usage = self.current_usage.swap(0, Ordering::Relaxed);
315        let new_key = generate_random_key();
316        state.key_sequence += 1;
317        let new_key_id = format_key_id(state.key_sequence);
318
319        state.keys.insert(new_key_id.clone(), new_key);
320        state.created_at.insert(new_key_id.clone(), Instant::now());
321        state.current_key_id = new_key_id.clone();
322
323        // Purge oldest keys beyond retention limit
324        self.purge_old_keys(&mut state);
325
326        let event = RotationEvent {
327            old_key_id,
328            new_key_id,
329            reason,
330            rotated_at: Instant::now(),
331            old_key_encryptions: old_usage,
332        };
333
334        // Log the rotation for audit
335        tracing::info!(
336            old_key = %event.old_key_id,
337            new_key = %event.new_key_id,
338            reason = %event.reason,
339            old_key_encryptions = event.old_key_encryptions,
340            "encryption key rotated"
341        );
342        metrics::counter!("chronix_auth_key_rotations_total", "reason" => event.reason.to_string())
343            .increment(1);
344
345        let mut events = self.events.write();
346        if events.len() >= self.max_events {
347            events.pop_front();
348        }
349        events.push_back(event.clone());
350
351        Some(event)
352    }
353
354    /// Check if rotation is needed based on the policy.
355    fn check_rotation_needed(&self, state: &RotatingState, usage: u64) -> Option<RotationReason> {
356        // Check usage-based rotation first (more urgent)
357        if let Some(max) = self.policy.max_encryptions
358            && usage >= max
359        {
360            return Some(RotationReason::MaxEncryptions);
361        }
362
363        // Check time-based rotation
364        if let Some(max_age) = self.policy.max_age
365            && let Some(created) = state.created_at.get(&state.current_key_id)
366            && created.elapsed() >= max_age
367        {
368            return Some(RotationReason::MaxAge);
369        }
370
371        None
372    }
373
374    /// Purge old keys beyond the retention limit.
375    fn purge_old_keys(&self, state: &mut RotatingState) {
376        let retain = self.policy.retain_old_keys + 1; // +1 for current key
377        while state.keys.len() > retain {
378            // Find the oldest non-current key
379            let oldest = state
380                .created_at
381                .iter()
382                .filter(|(id, _)| **id != state.current_key_id)
383                .min_by_key(|(_, created)| *created)
384                .map(|(id, _)| id.clone());
385
386            if let Some(oldest_id) = oldest {
387                if let Some(mut key_bytes) = state.keys.remove(&oldest_id) {
388                    key_bytes.zeroize();
389                }
390                state.created_at.remove(&oldest_id);
391                tracing::debug!(key_id = %oldest_id, "purged expired encryption key");
392                metrics::counter!("chronix_auth_keys_purged_total").increment(1);
393            } else {
394                break;
395            }
396        }
397    }
398
399    /// Manually trigger key rotation regardless of policy thresholds.
400    ///
401    /// Returns the rotation event.
402    pub fn rotate_now(&self) -> RotationEvent {
403        let old_usage = self.current_usage.swap(0, Ordering::Relaxed);
404        let mut state = self.state.write();
405
406        let old_key_id = state.current_key_id.clone();
407        let new_key = generate_random_key();
408        state.key_sequence += 1;
409        let new_key_id = format_key_id(state.key_sequence);
410
411        state.keys.insert(new_key_id.clone(), new_key);
412        state.created_at.insert(new_key_id.clone(), Instant::now());
413        state.current_key_id = new_key_id.clone();
414
415        self.purge_old_keys(&mut state);
416
417        let event = RotationEvent {
418            old_key_id,
419            new_key_id,
420            reason: RotationReason::Manual,
421            rotated_at: Instant::now(),
422            old_key_encryptions: old_usage,
423        };
424
425        tracing::info!(
426            old_key = %event.old_key_id,
427            new_key = %event.new_key_id,
428            reason = %event.reason,
429            old_key_encryptions = event.old_key_encryptions,
430            "encryption key rotated (manual)"
431        );
432        metrics::counter!("chronix_auth_key_rotations_total", "reason" => "manual").increment(1);
433
434        let mut events = self.events.write();
435        if events.len() >= self.max_events {
436            events.pop_front();
437        }
438        events.push_back(event.clone());
439
440        event
441    }
442
443    /// Record that the current key was used for an encryption operation.
444    ///
445    /// Call this from the `EncryptionService` after each `encrypt()` call
446    /// so that usage-based rotation can be tracked.
447    pub fn record_encryption(&self) {
448        self.current_usage.fetch_add(1, Ordering::Relaxed);
449    }
450
451    /// Get the current encryption count for the active key.
452    #[must_use]
453    pub fn current_key_usage(&self) -> u64 {
454        self.current_usage.load(Ordering::Relaxed)
455    }
456
457    /// Return all rotation events (most recent last).
458    #[must_use]
459    pub fn rotation_history(&self) -> Vec<RotationEvent> {
460        self.events.read().iter().cloned().collect()
461    }
462
463    /// Number of keys currently held (current + retained old).
464    #[must_use]
465    pub fn key_count(&self) -> usize {
466        self.state.read().keys.len()
467    }
468
469    /// Returns the current key's age (time since creation).
470    #[must_use]
471    pub fn current_key_age(&self) -> Duration {
472        let state = self.state.read();
473        state
474            .created_at
475            .get(&state.current_key_id)
476            .map(std::time::Instant::elapsed)
477            .unwrap_or_default()
478    }
479}
480
481impl KeyProvider for RotatingKeyProvider {
482    fn current_key(&self) -> Result<(SecretKey, String), AuthError> {
483        // Check if rotation is needed (cheap atomic read in hot path)
484        self.maybe_rotate();
485
486        let state = self.state.read();
487        let key_bytes = state
488            .keys
489            .get(&state.current_key_id)
490            .ok_or_else(|| {
491                AuthError::KeyNotFound(format!(
492                    "current key {} missing from state",
493                    state.current_key_id
494                ))
495            })?
496            .clone();
497
498        Ok((SecretKey::new(key_bytes), state.current_key_id.clone()))
499    }
500
501    fn key_by_id(&self, key_id: &str) -> Result<SecretKey, AuthError> {
502        let state = self.state.read();
503        state
504            .keys
505            .get(key_id)
506            .cloned()
507            .map(SecretKey::new)
508            .ok_or_else(|| AuthError::KeyNotFound(key_id.to_string()))
509    }
510
511    fn key_ids(&self) -> Vec<String> {
512        self.state.read().keys.keys().cloned().collect()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::auth::encryption::EncryptionService;
520
521    #[test]
522    fn default_policy_values() {
523        let policy = RotationPolicy::default();
524        assert_eq!(policy.max_age, Some(Duration::from_secs(86400)));
525        assert_eq!(policy.max_encryptions, Some(1 << 31));
526        assert_eq!(policy.retain_old_keys, 5);
527    }
528
529    #[test]
530    fn builder_creates_custom_policy() {
531        let policy = RotationPolicy::builder()
532            .max_age(Duration::from_secs(3600))
533            .max_encryptions(500_000)
534            .retain_old_keys(3)
535            .build();
536
537        assert_eq!(policy.max_age, Some(Duration::from_secs(3600)));
538        assert_eq!(policy.max_encryptions, Some(500_000));
539        assert_eq!(policy.retain_old_keys, 3);
540    }
541
542    #[test]
543    fn new_provider_generates_initial_key() {
544        let provider = RotatingKeyProvider::new(RotationPolicy::default());
545        assert_eq!(provider.key_count(), 1);
546
547        let (key, id) = provider.current_key().unwrap();
548        assert_eq!(key.as_bytes().len(), 32);
549        assert_eq!(id, "rk-1");
550    }
551
552    #[test]
553    fn with_initial_key_accepts_valid_key() {
554        let key = vec![0xAB; 32];
555        let provider = RotatingKeyProvider::with_initial_key(
556            key.clone(),
557            "my-key-1",
558            RotationPolicy::default(),
559        )
560        .unwrap();
561
562        let (retrieved, id) = provider.current_key().unwrap();
563        assert_eq!(retrieved.as_bytes(), &key);
564        assert_eq!(id, "my-key-1");
565    }
566
567    #[test]
568    fn with_initial_key_rejects_wrong_size() {
569        let result = RotatingKeyProvider::with_initial_key(
570            vec![0u8; 16], // too short
571            "bad-key",
572            RotationPolicy::default(),
573        );
574        assert!(result.is_err());
575    }
576
577    #[test]
578    fn manual_rotation_creates_new_key() {
579        let provider = RotatingKeyProvider::new(RotationPolicy::default());
580
581        let (_, id1) = provider.current_key().unwrap();
582        assert_eq!(id1, "rk-1");
583
584        let event = provider.rotate_now();
585        assert_eq!(event.old_key_id, "rk-1");
586        assert_eq!(event.new_key_id, "rk-2");
587        assert_eq!(event.reason, RotationReason::Manual);
588
589        let (_, id2) = provider.current_key().unwrap();
590        assert_eq!(id2, "rk-2");
591        assert_eq!(provider.key_count(), 2);
592    }
593
594    #[test]
595    fn old_keys_still_accessible_after_rotation() {
596        let provider = RotatingKeyProvider::new(RotationPolicy::default());
597
598        // Get key-1
599        let (key1, _) = provider.current_key().unwrap();
600        let key1_bytes = key1.as_bytes().to_vec();
601
602        // Rotate to key-2
603        provider.rotate_now();
604
605        // key-1 is still accessible
606        let old_key = provider.key_by_id("rk-1").unwrap();
607        assert_eq!(old_key.as_bytes(), &key1_bytes);
608    }
609
610    #[test]
611    fn encryption_roundtrip_after_rotation() {
612        let provider = RotatingKeyProvider::new(RotationPolicy::default());
613        let svc = EncryptionService::new(Box::new(provider));
614
615        // Encrypt with initial key
616        let plaintext = b"secret data before rotation";
617        let (ct1, kid1) = svc.encrypt(plaintext).unwrap();
618        assert_eq!(kid1, "rk-1");
619
620        // We can't rotate via svc — this tests the normal path
621        let dec = svc.decrypt(&ct1, &kid1).unwrap();
622        assert_eq!(dec, plaintext);
623    }
624
625    #[test]
626    fn usage_based_rotation_triggers() {
627        let policy = RotationPolicy::builder()
628            .max_encryptions(3) // rotate after 3 encryptions
629            .retain_old_keys(2)
630            .build();
631
632        let provider = RotatingKeyProvider::new(policy);
633
634        // Simulate 3 encryptions
635        provider.record_encryption();
636        provider.record_encryption();
637        provider.record_encryption();
638
639        // Next current_key() should trigger rotation
640        let (_, id) = provider.current_key().unwrap();
641        assert_eq!(id, "rk-2", "should have auto-rotated to rk-2");
642        assert_eq!(provider.key_count(), 2);
643
644        // Rotation event should be recorded
645        let history = provider.rotation_history();
646        assert_eq!(history.len(), 1);
647        assert_eq!(history[0].reason, RotationReason::MaxEncryptions);
648        assert_eq!(history[0].old_key_encryptions, 3);
649    }
650
651    #[test]
652    fn key_purge_respects_retention_limit() {
653        let policy = RotationPolicy::builder()
654            .max_encryptions(1_000_000) // won't auto-trigger
655            .retain_old_keys(2)         // keep 2 old + 1 current = 3 max
656            .build();
657
658        let provider = RotatingKeyProvider::new(policy);
659        // rk-1 is current
660
661        provider.rotate_now(); // rk-2 current, rk-1 old
662        assert_eq!(provider.key_count(), 2);
663
664        provider.rotate_now(); // rk-3 current, rk-1 + rk-2 old
665        assert_eq!(provider.key_count(), 3);
666
667        provider.rotate_now(); // rk-4 current, rk-2 + rk-3 old, rk-1 purged
668        assert_eq!(provider.key_count(), 3);
669
670        // rk-1 should be purged
671        assert!(provider.key_by_id("rk-1").is_err());
672        // rk-2 and rk-3 still accessible
673        assert!(provider.key_by_id("rk-2").is_ok());
674        assert!(provider.key_by_id("rk-3").is_ok());
675        assert!(provider.key_by_id("rk-4").is_ok());
676    }
677
678    #[test]
679    fn rotation_history_bounded() {
680        let policy = RotationPolicy::default();
681
682        // Create provider with small event buffer for testing
683        let key = generate_random_key();
684        let key_id = format_key_id(1);
685        let now = Instant::now();
686
687        let mut keys = BTreeMap::new();
688        keys.insert(key_id.clone(), key);
689        let mut created_at = BTreeMap::new();
690        created_at.insert(key_id.clone(), now);
691
692        let provider = RotatingKeyProvider {
693            state: RwLock::new(RotatingState {
694                keys,
695                current_key_id: key_id,
696                created_at,
697                key_sequence: 1,
698            }),
699            policy,
700            current_usage: AtomicU64::new(0),
701            events: RwLock::new(VecDeque::new()),
702            max_events: 3, // small for testing
703        };
704
705        for _ in 0..5 {
706            provider.rotate_now();
707        }
708
709        let history = provider.rotation_history();
710        assert_eq!(history.len(), 3, "should cap at max_events");
711        // Most recent events should be retained
712        assert_eq!(history[2].new_key_id, "rk-6");
713    }
714
715    #[test]
716    fn current_key_age_increases() {
717        let provider = RotatingKeyProvider::new(RotationPolicy::default());
718        let age1 = provider.current_key_age();
719        std::thread::sleep(Duration::from_millis(10));
720        let age2 = provider.current_key_age();
721        assert!(age2 > age1);
722    }
723
724    #[test]
725    fn record_encryption_increments_counter() {
726        let provider = RotatingKeyProvider::new(RotationPolicy::default());
727        assert_eq!(provider.current_key_usage(), 0);
728
729        provider.record_encryption();
730        provider.record_encryption();
731        assert_eq!(provider.current_key_usage(), 2);
732    }
733
734    #[test]
735    fn key_ids_lists_all() {
736        let provider = RotatingKeyProvider::new(RotationPolicy::default());
737        provider.rotate_now();
738
739        let ids = provider.key_ids();
740        assert_eq!(ids.len(), 2);
741        assert!(ids.contains(&"rk-1".to_string()));
742        assert!(ids.contains(&"rk-2".to_string()));
743    }
744
745    #[test]
746    fn debug_does_not_leak_keys() {
747        let provider = RotatingKeyProvider::new(RotationPolicy::default());
748        let debug = format!("{provider:?}");
749
750        assert!(debug.contains("RotatingKeyProvider"));
751        assert!(debug.contains("current_key_id"));
752        // Should not contain raw key bytes
753        assert!(!debug.contains("0x"));
754    }
755
756    #[test]
757    fn concurrent_rotation_safety() {
758        use std::sync::Arc;
759
760        let policy = RotationPolicy::builder()
761            .max_encryptions(10)
762            .retain_old_keys(20)
763            .build();
764
765        let provider = Arc::new(RotatingKeyProvider::new(policy));
766        let mut handles = Vec::new();
767
768        // 4 threads each doing 50 encryptions
769        for _ in 0..4 {
770            let p = Arc::clone(&provider);
771            handles.push(std::thread::spawn(move || {
772                for _ in 0..50 {
773                    p.record_encryption();
774                    let _ = p.current_key().unwrap();
775                }
776            }));
777        }
778
779        for h in handles {
780            h.join().unwrap();
781        }
782
783        // Should have rotated multiple times
784        let history = provider.rotation_history();
785        assert!(
786            !history.is_empty(),
787            "should have auto-rotated at least once"
788        );
789    }
790
791    #[test]
792    fn encrypt_decrypt_across_rotation() {
793        let policy = RotationPolicy::builder()
794            .max_encryptions(1_000_000)
795            .retain_old_keys(5)
796            .build();
797
798        let provider = RotatingKeyProvider::new(policy);
799
800        // Encrypt with key rk-1
801        let svc = EncryptionService::new(Box::new(provider));
802        let (ct1, kid1) = svc.encrypt(b"data-1").unwrap();
803        assert_eq!(kid1, "rk-1");
804        let dec = svc.decrypt(&ct1, &kid1).unwrap();
805        assert_eq!(dec, b"data-1");
806    }
807
808    #[test]
809    fn rotation_reason_display() {
810        assert_eq!(RotationReason::MaxAge.to_string(), "max_age");
811        assert_eq!(
812            RotationReason::MaxEncryptions.to_string(),
813            "max_encryptions"
814        );
815        assert_eq!(RotationReason::Manual.to_string(), "manual");
816    }
817}