Skip to main content

kovra_core/
index.rs

1//! Embedded metadata index (ADR-0001 §A.4–6): a redb store treated as a
2//! **rebuildable cache**, never the source of truth.
3//!
4//! It holds **metadata only** — coordinate, environment/component/key,
5//! sensitivity, mode (literal/reference) + ref scheme, the **truncated**
6//! fingerprint (§10.4), timestamps, origin vault, and the record path. It
7//! **never** holds a value and never a full fingerprint (I12). The entries are
8//! AEAD-sealed at rest (the ADR's default lean), so the redb file carries no
9//! cleartext coordinates either.
10//!
11//! Because it is derived, losing or corrupting it is never data loss: it is
12//! rebuilt by scanning the records ([`Index::rebuild_from`]). The resolution
13//! path never reads it (ADR-0001 §A.5) — it serves enumeration only (`list`,
14//! Web UI inventory, shadowing, `doctor`).
15
16use std::path::Path;
17
18use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
19use serde::{Deserialize, Serialize};
20
21use crate::crypto::{KEY_LEN, SealedRecord, open_bytes, seal_bytes};
22use crate::error::CoreError;
23use crate::fingerprint::fingerprint;
24use crate::record::SecretRecord;
25use crate::sensitivity::Sensitivity;
26use crate::store;
27
28/// Default index filename within a vault directory.
29pub const INDEX_FILE: &str = "index.redb";
30
31/// id (blake3 storage id) → sealed `IndexEntry` JSON.
32const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
33/// Index-wide generation counter (rebuild marker). Not sensitive.
34const GEN: TableDefinition<&str, u64> = TableDefinition::new("generation");
35const GEN_KEY: &str = "g";
36
37/// Whether a record stores its value inline or points elsewhere.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RecordMode {
41    /// Value lives (encrypted) in the record.
42    Literal,
43    /// Record is a pointer to an external provider.
44    Reference,
45    /// An asymmetric keypair (KOV-12): a sealed private half (optional) and an
46    /// OpenSSH public half.
47    Keypair,
48    /// A TOTP enrollment (KOV-11): a sealed seed + non-secret params.
49    Totp,
50}
51
52/// One metadata row. Carries no value and only the truncated fingerprint (I12).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct IndexEntry {
55    /// Blake3 storage id (also the index key).
56    pub id: String,
57    /// Environment segment.
58    pub environment: String,
59    /// Component segment.
60    pub component: String,
61    /// Key segment.
62    pub key: String,
63    /// Sensitivity level.
64    pub sensitivity: Sensitivity,
65    /// Literal or reference.
66    pub mode: RecordMode,
67    /// Provider scheme for references (e.g. `azure-kv`); `None` for literals.
68    pub ref_scheme: Option<String>,
69    /// Truncated fingerprint of the value (§10.4); literals only, never full.
70    pub fingerprint: Option<String>,
71    /// Creation timestamp (from the record).
72    pub created: String,
73    /// Last-update timestamp (from the record).
74    pub updated: String,
75    /// Origin vault label, e.g. `global` or `projects/<name>`.
76    pub origin: String,
77    /// Path of the backing `.sec` record.
78    pub record_path: String,
79}
80
81impl IndexEntry {
82    /// Derive a metadata entry from an opened record. The value is read only to
83    /// compute the **truncated** fingerprint; it is not retained.
84    pub fn from_record(id: &str, record: &SecretRecord, origin: &str, record_path: &str) -> Self {
85        // Fields common to all modalities (same names and types in each arm).
86        let (sensitivity, environment, component, key, created, updated) = match record {
87            SecretRecord::Literal {
88                sensitivity,
89                environment,
90                component,
91                key,
92                created,
93                updated,
94                ..
95            }
96            | SecretRecord::Reference {
97                sensitivity,
98                environment,
99                component,
100                key,
101                created,
102                updated,
103                ..
104            }
105            | SecretRecord::Keypair {
106                sensitivity,
107                environment,
108                component,
109                key,
110                created,
111                updated,
112                ..
113            }
114            | SecretRecord::Totp {
115                sensitivity,
116                environment,
117                component,
118                key,
119                created,
120                updated,
121                ..
122            } => (sensitivity, environment, component, key, created, updated),
123        };
124        // The fields that distinguish the modalities. The keypair's fingerprint
125        // is of its **public** key (public material — safe to index, lets an
126        // operator confirm the key without ever touching the private half, I12);
127        // the private half is never fingerprinted into the index.
128        let (mode, ref_scheme, fingerprint) = match record {
129            SecretRecord::Literal { value, .. } => {
130                (RecordMode::Literal, None, Some(fingerprint(value.expose())))
131            }
132            SecretRecord::Reference { reference, .. } => {
133                (RecordMode::Reference, ref_scheme(reference), None)
134            }
135            SecretRecord::Keypair { public, .. } => (
136                RecordMode::Keypair,
137                None,
138                Some(fingerprint(public.as_bytes())),
139            ),
140            // The TOTP fingerprint is of the **non-secret parameters** only
141            // (algorithm/digits/period) — never the seed (I12). It lets an
142            // operator tell two enrollments apart without ever touching the seed.
143            SecretRecord::Totp {
144                algorithm,
145                digits,
146                period,
147                ..
148            } => (
149                RecordMode::Totp,
150                None,
151                Some(fingerprint(
152                    format!("totp:{}:{digits}:{period}", algorithm.as_str()).as_bytes(),
153                )),
154            ),
155        };
156        IndexEntry {
157            id: id.to_string(),
158            environment: environment.clone(),
159            component: component.clone(),
160            key: key.clone(),
161            sensitivity: *sensitivity,
162            mode,
163            ref_scheme,
164            fingerprint,
165            created: created.clone(),
166            updated: updated.clone(),
167            origin: origin.to_string(),
168            record_path: record_path.to_string(),
169        }
170    }
171
172    /// The canonical coordinate path `<env>/<component>/<key>` — derived, not
173    /// stored (it is exactly the three segment fields joined).
174    pub fn coordinate(&self) -> String {
175        format!("{}/{}/{}", self.environment, self.component, self.key)
176    }
177}
178
179/// The scheme of a reference URI (`azure-kv://...` → `azure-kv`).
180fn ref_scheme(reference: &str) -> Option<String> {
181    reference
182        .split_once("://")
183        .map(|(scheme, _)| scheme.to_string())
184}
185
186/// An embedded metadata index over one vault directory.
187pub struct Index {
188    db: Database,
189}
190
191impl Index {
192    /// Open (or create) the index at `dir/index.redb`.
193    pub fn open(dir: &Path) -> Result<Self, CoreError> {
194        store::ensure_dir(dir)?;
195        let path = dir.join(INDEX_FILE);
196        let existed = path.exists();
197        let db = Database::create(&path).map_err(|e| CoreError::Index(e.to_string()))?;
198        if !existed {
199            store::restrict(&path, 0o600)?;
200        }
201        Ok(Self { db })
202    }
203
204    /// Insert or replace an entry. The entry is sealed before it touches disk.
205    pub fn upsert(&self, entry: &IndexEntry, key: &[u8; KEY_LEN]) -> Result<(), CoreError> {
206        let plaintext =
207            serde_json::to_vec(entry).map_err(|e| CoreError::Serialization(e.to_string()))?;
208        let sealed = seal_bytes(&plaintext, key)?;
209        let blob =
210            serde_json::to_vec(&sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
211
212        let txn = self.db.begin_write().map_err(idx)?;
213        {
214            let mut table = txn.open_table(META).map_err(idx)?;
215            table
216                .insert(entry.id.as_str(), blob.as_slice())
217                .map_err(idx)?;
218        }
219        txn.commit().map_err(idx)?;
220        Ok(())
221    }
222
223    /// Remove an entry by id (no-op if absent).
224    pub fn remove(&self, id: &str) -> Result<(), CoreError> {
225        let txn = self.db.begin_write().map_err(idx)?;
226        {
227            let mut table = txn.open_table(META).map_err(idx)?;
228            table.remove(id).map_err(idx)?;
229        }
230        txn.commit().map_err(idx)?;
231        Ok(())
232    }
233
234    /// Enumerate all metadata entries (unsealing each). Never decrypts a value —
235    /// values live only in `.sec` records.
236    pub fn list(&self, key: &[u8; KEY_LEN]) -> Result<Vec<IndexEntry>, CoreError> {
237        let txn = self.db.begin_read().map_err(idx)?;
238        let table = match txn.open_table(META) {
239            Ok(t) => t,
240            // No table yet → empty index.
241            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
242            Err(e) => return Err(CoreError::Index(e.to_string())),
243        };
244        let mut out = Vec::new();
245        for row in table.iter().map_err(idx)? {
246            let (_id, blob) = row.map_err(idx)?;
247            let sealed: SealedRecord = serde_json::from_slice(blob.value())
248                .map_err(|e| CoreError::Serialization(e.to_string()))?;
249            let plaintext = open_bytes(&sealed, key)?;
250            let entry: IndexEntry = serde_json::from_slice(&plaintext)
251                .map_err(|e| CoreError::Serialization(e.to_string()))?;
252            out.push(entry);
253        }
254        out.sort_by(|a, b| a.id.cmp(&b.id));
255        Ok(out)
256    }
257
258    /// The current generation counter (0 until the first rebuild).
259    pub fn generation(&self) -> Result<u64, CoreError> {
260        let txn = self.db.begin_read().map_err(idx)?;
261        let table = match txn.open_table(GEN) {
262            Ok(t) => t,
263            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
264            Err(e) => return Err(CoreError::Index(e.to_string())),
265        };
266        Ok(table
267            .get(GEN_KEY)
268            .map_err(idx)?
269            .map(|v| v.value())
270            .unwrap_or(0))
271    }
272
273    /// Rebuild the index from the records in `store_dir`, tolerantly: corrupt
274    /// records are skipped (already quarantined by the loader). Clears the
275    /// existing table and bumps the generation counter. Self-healing — a stale
276    /// or lost index is reconstructed from the source of truth (ADR-0001 §A.6).
277    pub fn rebuild_from(
278        &self,
279        store_dir: &Path,
280        origin: &str,
281        key: &[u8; KEY_LEN],
282    ) -> Result<store::LoadOutcome, CoreError> {
283        let outcome = store::load_all(store_dir, key)?;
284        let next_gen = self.generation()?.saturating_add(1);
285
286        let txn = self.db.begin_write().map_err(idx)?;
287        // Clear by dropping and recreating the table.
288        txn.delete_table(META).map_err(idx)?;
289        {
290            let mut table = txn.open_table(META).map_err(idx)?;
291            for (id, record) in &outcome.records {
292                let path = store::record_path_for_id(store_dir, id);
293                let entry = IndexEntry::from_record(id, record, origin, &path.to_string_lossy());
294                let plaintext = serde_json::to_vec(&entry)
295                    .map_err(|e| CoreError::Serialization(e.to_string()))?;
296                let sealed = seal_bytes(&plaintext, key)?;
297                let blob = serde_json::to_vec(&sealed)
298                    .map_err(|e| CoreError::Serialization(e.to_string()))?;
299                table.insert(id.as_str(), blob.as_slice()).map_err(idx)?;
300            }
301            let mut gen_table = txn.open_table(GEN).map_err(idx)?;
302            gen_table.insert(GEN_KEY, next_gen).map_err(idx)?;
303        }
304        txn.commit().map_err(idx)?;
305        Ok(outcome)
306    }
307}
308
309/// Map any redb error to the opaque index error.
310fn idx<E: std::fmt::Display>(e: E) -> CoreError {
311    CoreError::Index(e.to_string())
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::coordinate::Coordinate;
318    use crate::crypto::seal;
319    use crate::secret::SecretValue;
320
321    fn key() -> [u8; KEY_LEN] {
322        [0x33; KEY_LEN]
323    }
324
325    fn literal(value: &str, k: &str) -> SecretRecord {
326        SecretRecord::Literal {
327            value: SecretValue::from(value),
328            sensitivity: Sensitivity::Medium,
329            revealable: false,
330            environment: "prod".to_string(),
331            component: "db".to_string(),
332            key: k.to_string(),
333            description: None,
334            created: "2026-05-30T00:00:00Z".to_string(),
335            updated: "2026-05-30T00:00:00Z".to_string(),
336        }
337    }
338
339    #[test]
340    fn upsert_then_list_round_trips() {
341        let dir = tempfile::tempdir().unwrap();
342        let index = Index::open(dir.path()).unwrap();
343        let entry = IndexEntry::from_record(
344            "abc",
345            &literal("hunter2", "password"),
346            "global",
347            "/x/abc.sec",
348        );
349        index.upsert(&entry, &key()).unwrap();
350
351        let listed = index.list(&key()).unwrap();
352        assert_eq!(listed.len(), 1);
353        assert_eq!(listed[0], entry);
354        assert_eq!(listed[0].mode, RecordMode::Literal);
355        assert!(listed[0].fingerprint.is_some());
356    }
357
358    #[test]
359    fn remove_drops_entry() {
360        let dir = tempfile::tempdir().unwrap();
361        let index = Index::open(dir.path()).unwrap();
362        let entry = IndexEntry::from_record("abc", &literal("v", "k"), "global", "/x/abc.sec");
363        index.upsert(&entry, &key()).unwrap();
364        index.remove("abc").unwrap();
365        assert!(index.list(&key()).unwrap().is_empty());
366    }
367
368    #[test]
369    fn reference_entry_has_scheme_and_no_fingerprint() {
370        let dir = tempfile::tempdir().unwrap();
371        let index = Index::open(dir.path()).unwrap();
372        let record = SecretRecord::Reference {
373            reference: "azure-kv://corp-kv/db-url".to_string(),
374            sensitivity: Sensitivity::High,
375            revealable: false,
376            environment: "prod".to_string(),
377            component: "db".to_string(),
378            key: "url".to_string(),
379            description: None,
380            created: "2026-05-30T00:00:00Z".to_string(),
381            updated: "2026-05-30T00:00:00Z".to_string(),
382        };
383        let entry = IndexEntry::from_record("ref1", &record, "global", "/x/ref1.sec");
384        index.upsert(&entry, &key()).unwrap();
385        let listed = index.list(&key()).unwrap();
386        assert_eq!(listed[0].mode, RecordMode::Reference);
387        assert_eq!(listed[0].ref_scheme.as_deref(), Some("azure-kv"));
388        assert!(listed[0].fingerprint.is_none());
389    }
390
391    #[test]
392    fn rebuild_reconstructs_and_bumps_generation() {
393        let dir = tempfile::tempdir().unwrap();
394        // Two records on disk.
395        let a: Coordinate = "secret:prod/db/a".parse().unwrap();
396        let b: Coordinate = "secret:prod/db/b".parse().unwrap();
397        store::write_record(dir.path(), &a, &seal(&literal("va", "a"), &key()).unwrap()).unwrap();
398        store::write_record(dir.path(), &b, &seal(&literal("vb", "b"), &key()).unwrap()).unwrap();
399
400        let index = Index::open(dir.path()).unwrap();
401        assert_eq!(index.generation().unwrap(), 0);
402
403        let outcome = index.rebuild_from(dir.path(), "global", &key()).unwrap();
404        assert_eq!(outcome.records.len(), 2);
405        assert_eq!(index.list(&key()).unwrap().len(), 2);
406        assert_eq!(index.generation().unwrap(), 1);
407
408        // Rebuilding again is idempotent in content but bumps the generation.
409        index.rebuild_from(dir.path(), "global", &key()).unwrap();
410        assert_eq!(index.list(&key()).unwrap().len(), 2);
411        assert_eq!(index.generation().unwrap(), 2);
412    }
413
414    #[test]
415    fn raw_index_bytes_hold_no_plaintext_or_full_fingerprint() {
416        let dir = tempfile::tempdir().unwrap();
417        let index = Index::open(dir.path()).unwrap();
418        let value = "super-secret-value";
419        let entry =
420            IndexEntry::from_record("abc", &literal(value, "password"), "global", "/x/abc.sec");
421        index.upsert(&entry, &key()).unwrap();
422        drop(index); // flush
423
424        let raw = std::fs::read(dir.path().join(INDEX_FILE)).unwrap();
425        // No plaintext value (I12) — it is never even in the entry.
426        assert!(!contains(&raw, value.as_bytes()));
427        // No cleartext coordinate (sealed at rest).
428        assert!(!contains(&raw, b"prod/db/password"));
429        // No full fingerprint — only the truncated one exists, and it is sealed.
430        let full = blake3::hash(value.as_bytes()).to_hex().to_string();
431        assert!(!contains(&raw, full.as_bytes()));
432    }
433
434    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
435        haystack.windows(needle.len()).any(|w| w == needle)
436    }
437}