use chrono::{DateTime, Utc};
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use uuid::Uuid;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyId(pub Uuid);
impl KeyId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn nil() -> Self {
Self(Uuid::nil())
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl Default for KeyId {
fn default() -> Self {
Self::nil()
}
}
impl std::fmt::Display for KeyId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RotationReason {
Initial,
Routine,
Manual,
Rtbf,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct AuditKey {
pub key_id: KeyId,
pub key: [u8; 32],
pub valid_from: DateTime<Utc>,
pub valid_until: Option<DateTime<Utc>>,
pub rotation_reason: RotationReason,
}
impl std::fmt::Debug for AuditKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditKey")
.field("key_id", &self.key_id)
.field("key", &"<redacted>")
.field("valid_from", &self.valid_from)
.field("valid_until", &self.valid_until)
.field("rotation_reason", &self.rotation_reason)
.finish()
}
}
const ACTIVE_MARKER_KEY: &[u8] = b"audit_key:active";
fn key_storage_key(key_id: &KeyId) -> Vec<u8> {
format!("audit_key:{}", key_id.0).into_bytes()
}
#[derive(Clone)]
pub struct AuditKeyStore {
ks: KeyspaceHandle,
}
impl AuditKeyStore {
pub fn new(ks: KeyspaceHandle) -> Self {
Self { ks }
}
pub async fn active(&self) -> Result<AuditKey, AppError> {
let id_bytes = self
.ks
.get_raw(ACTIVE_MARKER_KEY.to_vec())
.await?
.ok_or_else(|| {
AppError::NotFound(
"no active audit_key; call ensure_initial(master_seed) first".into(),
)
})?;
let id_str = String::from_utf8(id_bytes)
.map_err(|e| AppError::Internal(format!("invalid audit_key id encoding: {e}")))?;
let key_id = KeyId(
Uuid::parse_str(&id_str)
.map_err(|e| AppError::Internal(format!("invalid audit_key uuid: {e}")))?,
);
self.fetch(&key_id).await?.ok_or_else(|| {
AppError::Internal(format!(
"active marker points at unknown audit_key {key_id}"
))
})
}
pub async fn fetch(&self, key_id: &KeyId) -> Result<Option<AuditKey>, AppError> {
self.ks.get(key_storage_key(key_id)).await
}
pub async fn history(&self) -> Result<Vec<AuditKey>, AppError> {
let pairs = self.ks.prefix_iter_raw(b"audit_key:".to_vec()).await?;
let mut keys: Vec<AuditKey> = pairs
.into_iter()
.filter(|(k, _)| k.as_slice() != ACTIVE_MARKER_KEY)
.filter_map(|(_, v)| serde_json::from_slice::<AuditKey>(&v).ok())
.collect();
keys.sort_by_key(|k| std::cmp::Reverse(k.valid_from));
Ok(keys)
}
pub async fn ensure_initial(&self, master_seed: &[u8]) -> Result<AuditKey, AppError> {
if let Some(existing) = self.try_active().await? {
return Ok(existing);
}
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(None, master_seed)
.expand(b"vtc-audit-key/v2", &mut key)
.map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?;
let initial = AuditKey {
key_id: KeyId::new(),
key,
valid_from: Utc::now(),
valid_until: None,
rotation_reason: RotationReason::Initial,
};
self.persist(&initial).await?;
self.set_active(&initial.key_id).await?;
Ok(initial)
}
pub async fn rotate(&self, reason: RotationReason) -> Result<AuditKey, AppError> {
let now = Utc::now();
let mut prev = self.active().await?;
prev.valid_until = Some(now);
self.persist(&prev).await?;
let key = random_32_bytes();
let successor = AuditKey {
key_id: KeyId::new(),
key,
valid_from: now,
valid_until: None,
rotation_reason: reason,
};
self.persist(&successor).await?;
self.set_active(&successor.key_id).await?;
Ok(successor)
}
async fn try_active(&self) -> Result<Option<AuditKey>, AppError> {
let id_bytes = match self.ks.get_raw(ACTIVE_MARKER_KEY.to_vec()).await? {
Some(b) => b,
None => return Ok(None),
};
let id_str = String::from_utf8(id_bytes)
.map_err(|e| AppError::Internal(format!("invalid audit_key id encoding: {e}")))?;
let key_id = KeyId(
Uuid::parse_str(&id_str)
.map_err(|e| AppError::Internal(format!("invalid audit_key uuid: {e}")))?,
);
self.fetch(&key_id).await
}
async fn persist(&self, key: &AuditKey) -> Result<(), AppError> {
self.ks.insert(key_storage_key(&key.key_id), key).await
}
async fn set_active(&self, key_id: &KeyId) -> Result<(), AppError> {
self.ks
.insert_raw(
ACTIVE_MARKER_KEY.to_vec(),
key_id.0.to_string().into_bytes(),
)
.await
}
}
fn random_32_bytes() -> [u8; 32] {
let mut out = [0u8; 32];
rand::fill(&mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
let ks = store.keyspace("audit_key-test").expect("keyspace");
(ks, dir)
}
#[tokio::test]
async fn ensure_initial_is_deterministic() {
let (ks_a, _a) = temp_ks();
let (ks_b, _b) = temp_ks();
let seed = [0xAB; 32];
let store_a = AuditKeyStore::new(ks_a);
let store_b = AuditKeyStore::new(ks_b);
let a = store_a.ensure_initial(&seed).await.unwrap();
let b = store_b.ensure_initial(&seed).await.unwrap();
assert_eq!(a.key, b.key);
}
#[tokio::test]
async fn ensure_initial_is_idempotent() {
let (ks, _dir) = temp_ks();
let store = AuditKeyStore::new(ks);
let first = store.ensure_initial(&[0x01; 32]).await.unwrap();
let second = store.ensure_initial(&[0x99; 32]).await.unwrap();
assert_eq!(first.key_id, second.key_id);
assert_eq!(first.key, second.key);
}
#[tokio::test]
async fn rotate_generates_fresh_random_and_closes_prior() {
let (ks, _dir) = temp_ks();
let store = AuditKeyStore::new(ks);
let initial = store.ensure_initial(&[0x33; 32]).await.unwrap();
assert_eq!(initial.rotation_reason, RotationReason::Initial);
assert!(initial.valid_until.is_none());
let rotated = store.rotate(RotationReason::Rtbf).await.unwrap();
assert_eq!(rotated.rotation_reason, RotationReason::Rtbf);
assert_ne!(rotated.key_id, initial.key_id);
assert_ne!(rotated.key, initial.key);
assert!(rotated.valid_until.is_none());
let prior = store.fetch(&initial.key_id).await.unwrap().expect("prior");
assert!(prior.valid_until.is_some());
let active = store.active().await.unwrap();
assert_eq!(active.key_id, rotated.key_id);
}
#[tokio::test]
async fn history_lists_newest_first() {
let (ks, _dir) = temp_ks();
let store = AuditKeyStore::new(ks);
let k1 = store.ensure_initial(&[0x33; 32]).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let k2 = store.rotate(RotationReason::Routine).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let k3 = store.rotate(RotationReason::Manual).await.unwrap();
let history = store.history().await.unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history[0].key_id, k3.key_id);
assert_eq!(history[1].key_id, k2.key_id);
assert_eq!(history[2].key_id, k1.key_id);
}
#[tokio::test]
async fn active_is_not_found_before_initial() {
let (ks, _dir) = temp_ks();
let store = AuditKeyStore::new(ks);
let err = store.active().await.expect_err("no active key yet");
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn debug_redacts_key_material() {
let k = AuditKey {
key_id: KeyId::new(),
key: [0xAB; 32],
valid_from: Utc::now(),
valid_until: None,
rotation_reason: RotationReason::Initial,
};
let s = format!("{k:?}");
assert!(!s.contains("AB"), "key bytes leaked: {s}");
assert!(s.contains("<redacted>"), "missing redaction marker: {s}");
}
}