Skip to main content

kovra_core/
store.rs

1//! Per-secret sealed-file store: the source of truth (ADR-0001 §A.1–3).
2//!
3//! Each secret is one independently AEAD-sealed record, filed as a single file
4//! named `BLAKE3(coordinate).sec` under a vault directory. This is the
5//! durability boundary: one corrupt file loses **one** secret, never the whole
6//! vault.
7//!
8//! On-disk frame: a fixed header (magic [`FRAME_MAGIC`] + little-endian
9//! [`FRAME_VERSION`]) followed by the JSON-serialized
10//! [`SealedRecord`](crate::crypto::SealedRecord). The header lets the loader
11//! reject foreign/garbage files cleanly and version the frame independently of
12//! the sealed payload.
13//!
14//! Writes are atomic (temp → `fsync` → rotate previous to `.bak` → `rename`)
15//! and files are `0600`. The loader is **tolerant**: a record that fails its
16//! frame or its AEAD tag is quarantined and skipped, never aborting the scan.
17
18use std::fs::{self, File};
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use crate::coordinate::Coordinate;
23use crate::crypto::{KEY_LEN, SealedRecord, open};
24use crate::error::CoreError;
25use crate::record::SecretRecord;
26
27/// Frame magic: marks a file as a kovra sealed record.
28pub const FRAME_MAGIC: &[u8; 4] = b"KOVR";
29/// On-disk frame version (independent of the vault schema version).
30pub const FRAME_VERSION: u32 = 1;
31/// Extension for a sealed record file.
32pub const RECORD_EXT: &str = "sec";
33
34const HEADER_LEN: usize = 4 + 4; // magic + u32 version
35
36/// A record that could not be loaded, surfaced rather than aborting the scan.
37/// The reason is a coordinate-free description (I12) — never a value.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Quarantined {
40    /// The record id (file stem) that failed to load.
41    pub id: String,
42    /// Why it was quarantined (frame mismatch, AEAD failure, I/O error).
43    pub reason: String,
44}
45
46/// Outcome of [`load_all`]: every decryptable record plus the quarantined ones.
47#[derive(Debug, Default)]
48pub struct LoadOutcome {
49    /// Successfully opened records, keyed by record id (file stem).
50    pub records: Vec<(String, SecretRecord)>,
51    /// Records skipped because they failed to load (ADR-0001 §A.3).
52    pub quarantined: Vec<Quarantined>,
53}
54
55/// The on-disk path of a record within `dir`, given its storage id (the
56/// `<id>.sec` naming convention, owned here so callers never re-derive it).
57pub fn record_path_for_id(dir: &Path, id: &str) -> PathBuf {
58    dir.join(format!("{id}.{RECORD_EXT}"))
59}
60
61/// The on-disk path of a coordinate's record within `dir`.
62pub fn record_path(dir: &Path, coord: &Coordinate) -> Result<PathBuf, CoreError> {
63    Ok(record_path_for_id(dir, &coord.storage_id()?))
64}
65
66/// Frame a sealed record for storage: header + JSON payload.
67fn frame(sealed: &SealedRecord) -> Result<Vec<u8>, CoreError> {
68    let payload =
69        serde_json::to_vec(sealed).map_err(|e| CoreError::Serialization(e.to_string()))?;
70    let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
71    out.extend_from_slice(FRAME_MAGIC);
72    out.extend_from_slice(&FRAME_VERSION.to_le_bytes());
73    out.extend_from_slice(&payload);
74    Ok(out)
75}
76
77/// Parse a framed record back into a sealed record, validating the header.
78fn unframe(bytes: &[u8]) -> Result<SealedRecord, CoreError> {
79    if bytes.len() < HEADER_LEN || &bytes[..4] != FRAME_MAGIC {
80        return Err(CoreError::Serialization(
81            "not a kovra record frame".to_string(),
82        ));
83    }
84    let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
85    if version != FRAME_VERSION {
86        return Err(CoreError::Serialization(format!(
87            "unsupported record frame version {version}"
88        )));
89    }
90    serde_json::from_slice(&bytes[HEADER_LEN..])
91        .map_err(|e| CoreError::Serialization(e.to_string()))
92}
93
94/// Set restrictive permissions on a freshly created path. `0600` for files,
95/// `0700` for directories. No-op on non-unix targets (Windows is L13). The
96/// single owner of kovra's on-disk permission policy — the index reuses it for
97/// `index.redb`.
98#[cfg(unix)]
99pub(crate) fn restrict(path: &Path, mode: u32) -> Result<(), CoreError> {
100    use std::os::unix::fs::PermissionsExt;
101    fs::set_permissions(path, fs::Permissions::from_mode(mode))
102        .map_err(|e| CoreError::Io(format!("chmod {mode:o}: {e}")))
103}
104
105#[cfg(not(unix))]
106pub(crate) fn restrict(_path: &Path, _mode: u32) -> Result<(), CoreError> {
107    Ok(())
108}
109
110/// Create a vault directory (and parents) at `0700` if missing.
111pub fn ensure_dir(dir: &Path) -> Result<(), CoreError> {
112    if !dir.exists() {
113        fs::create_dir_all(dir).map_err(|e| CoreError::Io(format!("create {dir:?}: {e}")))?;
114        restrict(dir, 0o700)?;
115    }
116    Ok(())
117}
118
119/// Write a sealed record atomically: temp file → `fsync` → rotate any existing
120/// record to `.bak` → `rename` into place. The result is `0600`.
121pub fn write_record(
122    dir: &Path,
123    coord: &Coordinate,
124    sealed: &SealedRecord,
125) -> Result<(), CoreError> {
126    ensure_dir(dir)?;
127    let path = record_path(dir, coord)?;
128    let tmp = path.with_extension(format!("{RECORD_EXT}.tmp"));
129    let bytes = frame(sealed)?;
130
131    {
132        let mut f = File::create(&tmp).map_err(|e| CoreError::Io(format!("create tmp: {e}")))?;
133        f.write_all(&bytes)
134            .map_err(|e| CoreError::Io(format!("write tmp: {e}")))?;
135        f.sync_all()
136            .map_err(|e| CoreError::Io(format!("fsync tmp: {e}")))?;
137    }
138    restrict(&tmp, 0o600)?;
139
140    if path.exists() {
141        let bak = path.with_extension(format!("{RECORD_EXT}.bak"));
142        fs::rename(&path, &bak).map_err(|e| CoreError::Io(format!("rotate .bak: {e}")))?;
143    }
144    fs::rename(&tmp, &path).map_err(|e| CoreError::Io(format!("rename into place: {e}")))?;
145    Ok(())
146}
147
148/// Read and decrypt a single record by coordinate — the O(1) point-lookup path
149/// used by resolution (it never touches the index, ADR-0001 §A.5). Returns
150/// `Ok(None)` when the record does not exist.
151pub fn read_record(
152    dir: &Path,
153    coord: &Coordinate,
154    key: &[u8; KEY_LEN],
155) -> Result<Option<SecretRecord>, CoreError> {
156    let path = record_path(dir, coord)?;
157    if !path.exists() {
158        return Ok(None);
159    }
160    let bytes = fs::read(&path).map_err(|e| CoreError::Io(format!("read record: {e}")))?;
161    let sealed = unframe(&bytes)?;
162    Ok(Some(open(&sealed, key)?))
163}
164
165/// Load every record in a vault directory, tolerantly (ADR-0001 §A.3). A file
166/// that fails its frame or its AEAD tag is quarantined and skipped; the scan
167/// never aborts, so one corrupt record cannot hide the rest.
168pub fn load_all(dir: &Path, key: &[u8; KEY_LEN]) -> Result<LoadOutcome, CoreError> {
169    let mut outcome = LoadOutcome::default();
170    if !dir.exists() {
171        return Ok(outcome);
172    }
173    let entries = fs::read_dir(dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))?;
174    for entry in entries {
175        let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
176        let path = entry.path();
177        if path.extension().and_then(|e| e.to_str()) != Some(RECORD_EXT) {
178            continue; // skip .bak, .tmp, index, anything not a record
179        }
180        let id = path
181            .file_stem()
182            .and_then(|s| s.to_str())
183            .unwrap_or_default()
184            .to_string();
185
186        let opened = fs::read(&path)
187            .map_err(|e| CoreError::Io(format!("read: {e}")))
188            .and_then(|bytes| unframe(&bytes))
189            .and_then(|sealed| open(&sealed, key));
190
191        match opened {
192            Ok(record) => outcome.records.push((id, record)),
193            Err(e) => outcome.quarantined.push(Quarantined {
194                id,
195                reason: e.to_string(),
196            }),
197        }
198    }
199    Ok(outcome)
200}
201
202/// Remove a record (its `.sec` file). The `.bak` rotation, if any, is left in
203/// place. No-op if the record does not exist.
204pub fn delete_record(dir: &Path, coord: &Coordinate) -> Result<(), CoreError> {
205    let path = record_path(dir, coord)?;
206    if path.exists() {
207        fs::remove_file(&path).map_err(|e| CoreError::Io(format!("remove record: {e}")))?;
208    }
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::crypto::seal;
216    use crate::secret::SecretValue;
217    use crate::sensitivity::Sensitivity;
218
219    fn key() -> [u8; KEY_LEN] {
220        [0x11; KEY_LEN]
221    }
222
223    fn coord(s: &str) -> Coordinate {
224        s.parse().unwrap()
225    }
226
227    fn literal(value: &str) -> SecretRecord {
228        SecretRecord::Literal {
229            value: SecretValue::from(value),
230            sensitivity: Sensitivity::Medium,
231            revealable: false,
232            environment: "prod".to_string(),
233            component: "db".to_string(),
234            key: "password".to_string(),
235            description: None,
236            created: "2026-05-30T00:00:00Z".to_string(),
237            updated: "2026-05-30T00:00:00Z".to_string(),
238        }
239    }
240
241    #[test]
242    fn write_then_read_round_trips() {
243        let dir = tempfile::tempdir().unwrap();
244        let c = coord("secret:prod/db/password");
245        let sealed = seal(&literal("hunter2"), &key()).unwrap();
246        write_record(dir.path(), &c, &sealed).unwrap();
247
248        let got = read_record(dir.path(), &c, &key()).unwrap().unwrap();
249        match got {
250            SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"hunter2"),
251            other => panic!("expected literal, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn read_missing_is_none() {
257        let dir = tempfile::tempdir().unwrap();
258        let c = coord("secret:prod/db/absent");
259        assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
260    }
261
262    #[test]
263    fn record_file_has_extension_and_hashed_name() {
264        let dir = tempfile::tempdir().unwrap();
265        let c = coord("secret:prod/db/password");
266        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
267        let path = record_path(dir.path(), &c).unwrap();
268        assert!(path.exists());
269        // filename is the blake3 hex id, never the cleartext coordinate
270        let name = path.file_name().unwrap().to_str().unwrap();
271        assert!(name.ends_with(".sec"));
272        assert!(!name.contains("password"));
273    }
274
275    #[cfg(unix)]
276    #[test]
277    fn written_record_is_0600() {
278        use std::os::unix::fs::PermissionsExt;
279        let dir = tempfile::tempdir().unwrap();
280        let c = coord("secret:prod/db/password");
281        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
282        let mode = fs::metadata(record_path(dir.path(), &c).unwrap())
283            .unwrap()
284            .permissions()
285            .mode();
286        assert_eq!(mode & 0o777, 0o600);
287    }
288
289    #[test]
290    fn overwrite_rotates_previous_to_bak() {
291        let dir = tempfile::tempdir().unwrap();
292        let c = coord("secret:prod/db/password");
293        write_record(dir.path(), &c, &seal(&literal("v1"), &key()).unwrap()).unwrap();
294        write_record(dir.path(), &c, &seal(&literal("v2"), &key()).unwrap()).unwrap();
295
296        // current holds v2
297        let current = read_record(dir.path(), &c, &key()).unwrap().unwrap();
298        match current {
299            SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"v2"),
300            other => panic!("expected literal, got {other:?}"),
301        }
302        // a .bak sibling exists
303        let bak = record_path(dir.path(), &c)
304            .unwrap()
305            .with_extension(format!("{RECORD_EXT}.bak"));
306        assert!(bak.exists());
307    }
308
309    #[test]
310    fn load_all_quarantines_corrupt_and_loads_siblings() {
311        let dir = tempfile::tempdir().unwrap();
312        let good = coord("secret:prod/db/good");
313        let bad = coord("secret:prod/db/bad");
314        write_record(
315            dir.path(),
316            &good,
317            &seal(&literal("good-val"), &key()).unwrap(),
318        )
319        .unwrap();
320        write_record(
321            dir.path(),
322            &bad,
323            &seal(&literal("bad-val"), &key()).unwrap(),
324        )
325        .unwrap();
326
327        // Corrupt the ciphertext of the "bad" record (flip a byte past the frame
328        // header, so the frame parses but the AEAD tag fails).
329        let bad_path = record_path(dir.path(), &bad).unwrap();
330        let mut bytes = fs::read(&bad_path).unwrap();
331        let last = bytes.len() - 1;
332        bytes[last] ^= 0xff;
333        fs::write(&bad_path, &bytes).unwrap();
334
335        let outcome = load_all(dir.path(), &key()).unwrap();
336        assert_eq!(outcome.records.len(), 1, "the good record still loads");
337        assert_eq!(
338            outcome.quarantined.len(),
339            1,
340            "the bad record is quarantined"
341        );
342        assert_eq!(outcome.quarantined[0].id, bad.storage_id().unwrap());
343        // quarantine reason carries no value
344        assert!(!outcome.quarantined[0].reason.contains("bad-val"));
345    }
346
347    #[test]
348    fn load_all_quarantines_garbage_file() {
349        let dir = tempfile::tempdir().unwrap();
350        ensure_dir(dir.path()).unwrap();
351        fs::write(dir.path().join("deadbeef.sec"), b"not a frame").unwrap();
352        let outcome = load_all(dir.path(), &key()).unwrap();
353        assert!(outcome.records.is_empty());
354        assert_eq!(outcome.quarantined.len(), 1);
355    }
356
357    #[test]
358    fn delete_removes_record() {
359        let dir = tempfile::tempdir().unwrap();
360        let c = coord("secret:prod/db/password");
361        write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()).unwrap();
362        delete_record(dir.path(), &c).unwrap();
363        assert!(read_record(dir.path(), &c, &key()).unwrap().is_none());
364    }
365
366    #[test]
367    fn placeholder_coordinate_is_rejected() {
368        let dir = tempfile::tempdir().unwrap();
369        let c = coord("secret:${ENV}/db/password");
370        assert!(matches!(
371            write_record(dir.path(), &c, &seal(&literal("x"), &key()).unwrap()),
372            Err(CoreError::NotStorable(_))
373        ));
374    }
375}