grafos_securestore/
manager.rs

1//! Key epoch lifecycle manager.
2
3extern crate alloc;
4use alloc::boxed::Box;
5use alloc::vec::Vec;
6
7use grafos_locator::locator::MemRegionLocator;
8use zeroize::Zeroize;
9
10use crate::crypto::CryptoBackend;
11use crate::epoch::{EpochId, EpochInfo, EpochStatus};
12use crate::error::SecureStoreError;
13
14/// Manages encryption key epochs with lease-scoped lifecycle.
15///
16/// Each epoch generates a fresh key and records a [`MemRegionLocator`]
17/// describing where the key material lives in leased memory. When an epoch
18/// expires, its key becomes inaccessible — enforcing fail-closed semantics.
19pub struct KeyEpochManager {
20    crypto: Box<dyn CryptoBackend>,
21    epochs: Vec<EpochEntry>,
22    next_epoch_id: u64,
23}
24
25struct EpochEntry {
26    info: EpochInfo,
27    key: Vec<u8>,
28}
29
30impl KeyEpochManager {
31    /// Create a new manager with the given crypto backend.
32    pub fn new(crypto: Box<dyn CryptoBackend>) -> Self {
33        Self {
34            crypto,
35            epochs: Vec::new(),
36            next_epoch_id: 1,
37        }
38    }
39
40    /// Create a new key epoch that becomes the active epoch.
41    ///
42    /// Generates a fresh key via the crypto backend and records a locator for
43    /// the key material. Any previously active epoch is transitioned to
44    /// [`EpochStatus::Rotating`].
45    pub fn create_epoch(&mut self, now: u64, ttl_secs: u64) -> Result<EpochId, SecureStoreError> {
46        // Mark any existing Active epoch as Rotating
47        for entry in &mut self.epochs {
48            if entry.info.status == EpochStatus::Active {
49                entry.info.status = EpochStatus::Rotating;
50            }
51        }
52
53        let epoch_id = EpochId(self.next_epoch_id);
54        self.next_epoch_id += 1;
55
56        let key = self.crypto.generate_key();
57        let key_len = key.len() as u64;
58
59        // Locator describes where the key lives. We use the epoch ID as a
60        // synthetic lease_id and offset 0 since each epoch owns its own region.
61        let key_locator = MemRegionLocator::new(epoch_id.0 as u128, 0, key_len);
62
63        let info = EpochInfo {
64            epoch_id,
65            created_at: now,
66            expires_at: now.saturating_add(ttl_secs),
67            key_locator,
68            status: EpochStatus::Active,
69        };
70
71        self.epochs.push(EpochEntry { info, key });
72
73        Ok(epoch_id)
74    }
75
76    /// Return the currently active epoch, if any.
77    pub fn active_epoch(&self) -> Option<&EpochInfo> {
78        self.epochs
79            .iter()
80            .find(|e| e.info.status == EpochStatus::Active)
81            .map(|e| &e.info)
82    }
83
84    /// Rotate keys: create a new active epoch and mark the old one as Rotating.
85    ///
86    /// The old epoch's key remains accessible for decryption until
87    /// [`expire_old`](Self::expire_old) is called.
88    pub fn rotate(&mut self, now: u64, new_ttl_secs: u64) -> Result<EpochId, SecureStoreError> {
89        self.create_epoch(now, new_ttl_secs)
90    }
91
92    /// Renew the active epoch by extending its `expires_at`.
93    ///
94    /// Returns the new `expires_at` on success, or [`SecureStoreError::NoActiveEpoch`]
95    /// if no epoch is active.
96    pub fn renew_active(&mut self, duration_secs: u64) -> Result<u64, SecureStoreError> {
97        let entry = self
98            .epochs
99            .iter_mut()
100            .find(|e| e.info.status == EpochStatus::Active)
101            .ok_or(SecureStoreError::NoActiveEpoch)?;
102        entry.info.expires_at = entry.info.expires_at.saturating_add(duration_secs);
103        Ok(entry.info.expires_at)
104    }
105
106    /// Expire epochs whose `expires_at` is at or before `now`.
107    ///
108    /// Expired epochs have their keys zeroed and status set to
109    /// [`EpochStatus::Expired`]. Subsequent calls to [`get_key`](Self::get_key)
110    /// for these epochs will fail closed.
111    pub fn expire_old(&mut self, now: u64) {
112        for entry in &mut self.epochs {
113            if entry.info.status != EpochStatus::Expired && entry.info.expires_at <= now {
114                entry.info.status = EpochStatus::Expired;
115                // Zero the key material using zeroize (compiler-fence prevents elision)
116                entry.key.zeroize();
117            }
118        }
119    }
120
121    /// Retrieve the key material for a given epoch.
122    ///
123    /// Fails closed if the epoch is expired, missing, or has zeroed key material.
124    pub fn get_key(&self, epoch_id: EpochId) -> Result<&[u8], SecureStoreError> {
125        let entry = self
126            .epochs
127            .iter()
128            .find(|e| e.info.epoch_id == epoch_id)
129            .ok_or(SecureStoreError::EpochNotFound(epoch_id))?;
130
131        if entry.info.status == EpochStatus::Expired {
132            return Err(SecureStoreError::EpochExpired(epoch_id));
133        }
134
135        // Fail closed: if key is all zeros, it was wiped
136        if entry.key.iter().all(|&b| b == 0) {
137            return Err(SecureStoreError::KeyUnavailable(epoch_id));
138        }
139
140        Ok(&entry.key)
141    }
142
143    /// Return the epoch info for a given epoch ID, if it exists.
144    pub fn get_epoch(&self, epoch_id: EpochId) -> Option<&EpochInfo> {
145        self.epochs
146            .iter()
147            .find(|e| e.info.epoch_id == epoch_id)
148            .map(|e| &e.info)
149    }
150
151    /// Total number of epochs (active, rotating, and expired).
152    pub fn epoch_count(&self) -> usize {
153        self.epochs.len()
154    }
155
156    /// Access the crypto backend (used by EncryptedBlobStore).
157    pub(crate) fn crypto(&self) -> &dyn CryptoBackend {
158        &*self.crypto
159    }
160}