Skip to main content

vti_common/audit/
key_store.rs

1//! `audit_key` lifecycle — HKDF-derived initial key, fresh-random
2//! rotations, indefinite retention.
3//!
4//! See plan **D3 + D10** in `tasks/vtc-mvp/plan.md` for the full
5//! rationale. Highlights:
6//!
7//! - **Algorithm**: HMAC-SHA256 over UTF-8 DID bytes.
8//! - **Initial key**: deterministic
9//!   `HKDF-SHA256(master_seed, info: "vtc-audit-key/v2")` — so a
10//!   backup+restore on the same master seed reproduces it.
11//! - **Subsequent rotations**: 32 fresh random bytes via `rand::fill`
12//!   (the workspace pattern; backed by the OS RNG). Deterministic
13//!   rotations would defeat the point of RTBF — a rotated key's
14//!   predecessor needs to be genuinely unrecoverable from the seed
15//!   alone.
16//! - **Retention**: every prior key stays in the keyspace under its
17//!   own `audit_key:<key_id>` entry. 32 bytes × one rotation/year ×
18//!   100 years = 3.2 KB. Lookups walk newest-first; the active key
19//!   answers the typical case and pre-rotation hashes only need
20//!   older keys during compliance investigations.
21//!
22//! ## Storage layout
23//!
24//! Two key spaces under the `audit_key` keyspace:
25//!
26//! - `audit_key:<key_id>` → [`AuditKey`] (JSON-encoded, encrypted via
27//!   the standard [`crate::store::KeyspaceHandle`] encryption layer
28//!   if the consumer enables it).
29//! - `audit_key:active` → `<key_id>` as bytes — the marker for the
30//!   currently-issuing key. Updated atomically on rotation.
31
32use 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/// Stable identifier for an audit_key. Wrapper around [`Uuid`] so
42/// public APIs stay typed.
43#[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/// Why an [`AuditKey`] was rotated. Recorded on the **successor** key
73/// so an investigator can tell why a particular epoch ended.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub enum RotationReason {
76    /// Initial key derived from the master seed via HKDF. Always
77    /// present as the first row.
78    Initial,
79    /// Routine age-triggered rotation (the audit background task fired).
80    Routine,
81    /// Operator invoked the rotate CLI / endpoint.
82    Manual,
83    /// Right-to-be-forgotten override — the rotation closes the
84    /// previous epoch and makes its hashes opaque.
85    Rtbf,
86}
87
88/// A persisted audit_key. Stored under `audit_key:<key_id>`. Manual
89/// [`std::fmt::Debug`] redacts the key material so a stray
90/// `tracing::debug!(?key, …)` never leaks it.
91#[derive(Clone, Serialize, Deserialize)]
92pub struct AuditKey {
93    pub key_id: KeyId,
94    /// 32-byte HMAC-SHA256 key. Serialised as a JSON array of bytes;
95    /// in production the surrounding keyspace handle should be
96    /// configured with the workspace's standard encryption-at-rest
97    /// layer so the value never touches disk in the clear.
98    pub key: [u8; 32],
99    pub valid_from: DateTime<Utc>,
100    /// `None` for the currently-active key; populated on rotation.
101    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
117// ---------------------------------------------------------------------------
118// Storage keys
119// ---------------------------------------------------------------------------
120
121const 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// ---------------------------------------------------------------------------
128// AuditKeyStore
129// ---------------------------------------------------------------------------
130
131/// Manager for the audit_key history under a single keyspace.
132///
133/// All methods are async because the underlying keyspace I/O is
134/// async. Concurrent rotations on the same store are *not*
135/// serialised in this layer — the caller (services) owns the
136/// invariant that rotation happens from a single coordinator path.
137#[derive(Clone)]
138pub struct AuditKeyStore {
139    ks: KeyspaceHandle,
140}
141
142impl AuditKeyStore {
143    /// Wrap a keyspace handle. The caller is responsible for
144    /// configuring encryption-at-rest if desired.
145    pub fn new(ks: KeyspaceHandle) -> Self {
146        Self { ks }
147    }
148
149    /// Read the currently active key. Returns
150    /// [`AppError::NotFound`] if no initial key has been derived yet
151    /// — callers should invoke [`Self::ensure_initial`] on boot.
152    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    /// Fetch a specific key by id. Used by verifiers walking history
176    /// to find the key that produced a given hash.
177    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    /// List every key in the store, newest first. Used by
182    /// `verify_actor`-style helpers in [`super::writer::AuditWriter`]
183    /// to walk history when the envelope's `audit_key_id` is
184    /// unavailable (defensive — every envelope written here has one).
185    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    /// Derive the initial audit_key from `master_seed` and persist it.
197    /// Idempotent: if an initial key already exists, returns it
198    /// unchanged. Safe to call on every daemon start.
199    ///
200    /// The derivation is deterministic
201    /// (`HKDF-SHA256(master_seed, info: "vtc-audit-key/v2")`) so a
202    /// backup+restore on the same seed reproduces the initial key
203    /// and pre-rotation hashes stay verifiable.
204    ///
205    /// Info string bumped from `/v1` to `/v2` alongside the
206    /// VTA-driven-keys rework (`tasks/vtc-mvp/vta-driven-keys.md`
207    /// §5.2) — the IKM is now a 32-byte Ed25519 private from the
208    /// VTA bundle, not a 64-byte BIP-39 seed.
209    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    /// Rotate the active key. The previous key gets `valid_until: now`
232    /// and a fresh-random successor is generated + activated.
233    /// Returns the new active key.
234    ///
235    /// Concurrent rotations are **not** safe at this layer — the
236    /// caller must hold a logical exclusivity lock.
237    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    /// Look up the active marker without raising if it's absent.
257    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// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[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        // Same seed → same HKDF output, even though the key_id /
324        // valid_from differ. The 32-byte key bytes are the load-bearing
325        // determinism.
326        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        // The seed argument is *ignored* on the second call — once an
338        // initial key exists, ensure_initial returns it unchanged.
339        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        // Initial now has a `valid_until` populated.
359        let prior = store.fetch(&initial.key_id).await.unwrap().expect("prior");
360        assert!(prior.valid_until.is_some());
361
362        // Active reads return the successor.
363        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}