1use chrono::{DateTime, Utc};
33use hkdf::Hkdf;
34use serde::{Deserialize, Serialize};
35use sha2::Sha256;
36use uuid::Uuid;
37
38use crate::error::AppError;
39use crate::store::KeyspaceHandle;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub struct KeyId(pub Uuid);
45
46impl KeyId {
47 pub fn new() -> Self {
48 Self(Uuid::new_v4())
49 }
50
51 pub fn nil() -> Self {
52 Self(Uuid::nil())
53 }
54
55 pub fn as_uuid(&self) -> Uuid {
56 self.0
57 }
58}
59
60impl Default for KeyId {
61 fn default() -> Self {
62 Self::nil()
63 }
64}
65
66impl std::fmt::Display for KeyId {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 self.0.fmt(f)
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub enum RotationReason {
76 Initial,
79 Routine,
81 Manual,
83 Rtbf,
86}
87
88#[derive(Clone, Serialize, Deserialize)]
92pub struct AuditKey {
93 pub key_id: KeyId,
94 pub key: [u8; 32],
99 pub valid_from: DateTime<Utc>,
100 pub valid_until: Option<DateTime<Utc>>,
102 pub rotation_reason: RotationReason,
103}
104
105impl std::fmt::Debug for AuditKey {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("AuditKey")
108 .field("key_id", &self.key_id)
109 .field("key", &"<redacted>")
110 .field("valid_from", &self.valid_from)
111 .field("valid_until", &self.valid_until)
112 .field("rotation_reason", &self.rotation_reason)
113 .finish()
114 }
115}
116
117const ACTIVE_MARKER_KEY: &[u8] = b"audit_key:active";
122
123fn key_storage_key(key_id: &KeyId) -> Vec<u8> {
124 format!("audit_key:{}", key_id.0).into_bytes()
125}
126
127#[derive(Clone)]
138pub struct AuditKeyStore {
139 ks: KeyspaceHandle,
140}
141
142impl AuditKeyStore {
143 pub fn new(ks: KeyspaceHandle) -> Self {
146 Self { ks }
147 }
148
149 pub async fn active(&self) -> Result<AuditKey, AppError> {
153 let id_bytes = self
154 .ks
155 .get_raw(ACTIVE_MARKER_KEY.to_vec())
156 .await?
157 .ok_or_else(|| {
158 AppError::NotFound(
159 "no active audit_key; call ensure_initial(master_seed) first".into(),
160 )
161 })?;
162 let id_str = String::from_utf8(id_bytes)
163 .map_err(|e| AppError::Internal(format!("invalid audit_key id encoding: {e}")))?;
164 let key_id = KeyId(
165 Uuid::parse_str(&id_str)
166 .map_err(|e| AppError::Internal(format!("invalid audit_key uuid: {e}")))?,
167 );
168 self.fetch(&key_id).await?.ok_or_else(|| {
169 AppError::Internal(format!(
170 "active marker points at unknown audit_key {key_id}"
171 ))
172 })
173 }
174
175 pub async fn fetch(&self, key_id: &KeyId) -> Result<Option<AuditKey>, AppError> {
178 self.ks.get(key_storage_key(key_id)).await
179 }
180
181 pub async fn history(&self) -> Result<Vec<AuditKey>, AppError> {
186 let pairs = self.ks.prefix_iter_raw(b"audit_key:".to_vec()).await?;
187 let mut keys: Vec<AuditKey> = pairs
188 .into_iter()
189 .filter(|(k, _)| k.as_slice() != ACTIVE_MARKER_KEY)
190 .filter_map(|(_, v)| serde_json::from_slice::<AuditKey>(&v).ok())
191 .collect();
192 keys.sort_by_key(|k| std::cmp::Reverse(k.valid_from));
193 Ok(keys)
194 }
195
196 pub async fn ensure_initial(&self, master_seed: &[u8]) -> Result<AuditKey, AppError> {
210 if let Some(existing) = self.try_active().await? {
211 return Ok(existing);
212 }
213
214 let mut key = [0u8; 32];
215 Hkdf::<Sha256>::new(None, master_seed)
216 .expand(b"vtc-audit-key/v2", &mut key)
217 .map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?;
218
219 let initial = AuditKey {
220 key_id: KeyId::new(),
221 key,
222 valid_from: Utc::now(),
223 valid_until: None,
224 rotation_reason: RotationReason::Initial,
225 };
226 self.persist(&initial).await?;
227 self.set_active(&initial.key_id).await?;
228 Ok(initial)
229 }
230
231 pub async fn rotate(&self, reason: RotationReason) -> Result<AuditKey, AppError> {
238 let now = Utc::now();
239 let mut prev = self.active().await?;
240 prev.valid_until = Some(now);
241 self.persist(&prev).await?;
242
243 let key = random_32_bytes();
244 let successor = AuditKey {
245 key_id: KeyId::new(),
246 key,
247 valid_from: now,
248 valid_until: None,
249 rotation_reason: reason,
250 };
251 self.persist(&successor).await?;
252 self.set_active(&successor.key_id).await?;
253 Ok(successor)
254 }
255
256 async fn try_active(&self) -> Result<Option<AuditKey>, AppError> {
258 let id_bytes = match self.ks.get_raw(ACTIVE_MARKER_KEY.to_vec()).await? {
259 Some(b) => b,
260 None => return Ok(None),
261 };
262 let id_str = String::from_utf8(id_bytes)
263 .map_err(|e| AppError::Internal(format!("invalid audit_key id encoding: {e}")))?;
264 let key_id = KeyId(
265 Uuid::parse_str(&id_str)
266 .map_err(|e| AppError::Internal(format!("invalid audit_key uuid: {e}")))?,
267 );
268 self.fetch(&key_id).await
269 }
270
271 async fn persist(&self, key: &AuditKey) -> Result<(), AppError> {
272 self.ks.insert(key_storage_key(&key.key_id), key).await
273 }
274
275 async fn set_active(&self, key_id: &KeyId) -> Result<(), AppError> {
276 self.ks
277 .insert_raw(
278 ACTIVE_MARKER_KEY.to_vec(),
279 key_id.0.to_string().into_bytes(),
280 )
281 .await
282 }
283}
284
285fn random_32_bytes() -> [u8; 32] {
286 let mut out = [0u8; 32];
287 rand::fill(&mut out);
288 out
289}
290
291#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::config::StoreConfig;
299 use crate::store::Store;
300
301 fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
302 let dir = tempfile::tempdir().expect("tempdir");
303 let cfg = StoreConfig {
304 data_dir: dir.path().to_path_buf(),
305 };
306 let store = Store::open(&cfg).expect("store");
307 let ks = store.keyspace("audit_key-test").expect("keyspace");
308 (ks, dir)
309 }
310
311 #[tokio::test]
312 async fn ensure_initial_is_deterministic() {
313 let (ks_a, _a) = temp_ks();
314 let (ks_b, _b) = temp_ks();
315 let seed = [0xAB; 32];
316
317 let store_a = AuditKeyStore::new(ks_a);
318 let store_b = AuditKeyStore::new(ks_b);
319
320 let a = store_a.ensure_initial(&seed).await.unwrap();
321 let b = store_b.ensure_initial(&seed).await.unwrap();
322
323 assert_eq!(a.key, b.key);
327 }
328
329 #[tokio::test]
330 async fn ensure_initial_is_idempotent() {
331 let (ks, _dir) = temp_ks();
332 let store = AuditKeyStore::new(ks);
333
334 let first = store.ensure_initial(&[0x01; 32]).await.unwrap();
335 let second = store.ensure_initial(&[0x99; 32]).await.unwrap();
336
337 assert_eq!(first.key_id, second.key_id);
340 assert_eq!(first.key, second.key);
341 }
342
343 #[tokio::test]
344 async fn rotate_generates_fresh_random_and_closes_prior() {
345 let (ks, _dir) = temp_ks();
346 let store = AuditKeyStore::new(ks);
347
348 let initial = store.ensure_initial(&[0x33; 32]).await.unwrap();
349 assert_eq!(initial.rotation_reason, RotationReason::Initial);
350 assert!(initial.valid_until.is_none());
351
352 let rotated = store.rotate(RotationReason::Rtbf).await.unwrap();
353 assert_eq!(rotated.rotation_reason, RotationReason::Rtbf);
354 assert_ne!(rotated.key_id, initial.key_id);
355 assert_ne!(rotated.key, initial.key);
356 assert!(rotated.valid_until.is_none());
357
358 let prior = store.fetch(&initial.key_id).await.unwrap().expect("prior");
360 assert!(prior.valid_until.is_some());
361
362 let active = store.active().await.unwrap();
364 assert_eq!(active.key_id, rotated.key_id);
365 }
366
367 #[tokio::test]
368 async fn history_lists_newest_first() {
369 let (ks, _dir) = temp_ks();
370 let store = AuditKeyStore::new(ks);
371
372 let k1 = store.ensure_initial(&[0x33; 32]).await.unwrap();
373 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
374 let k2 = store.rotate(RotationReason::Routine).await.unwrap();
375 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
376 let k3 = store.rotate(RotationReason::Manual).await.unwrap();
377
378 let history = store.history().await.unwrap();
379 assert_eq!(history.len(), 3);
380 assert_eq!(history[0].key_id, k3.key_id);
381 assert_eq!(history[1].key_id, k2.key_id);
382 assert_eq!(history[2].key_id, k1.key_id);
383 }
384
385 #[tokio::test]
386 async fn active_is_not_found_before_initial() {
387 let (ks, _dir) = temp_ks();
388 let store = AuditKeyStore::new(ks);
389 let err = store.active().await.expect_err("no active key yet");
390 assert!(matches!(err, AppError::NotFound(_)));
391 }
392
393 #[test]
394 fn debug_redacts_key_material() {
395 let k = AuditKey {
396 key_id: KeyId::new(),
397 key: [0xAB; 32],
398 valid_from: Utc::now(),
399 valid_until: None,
400 rotation_reason: RotationReason::Initial,
401 };
402 let s = format!("{k:?}");
403 assert!(!s.contains("AB"), "key bytes leaked: {s}");
404 assert!(s.contains("<redacted>"), "missing redaction marker: {s}");
405 }
406}