Skip to main content

krypton_core/vault/
mod.rs

1//! Multi-file encrypted vaults.
2//!
3//! # Layout
4//!
5//! ```text
6//! vault/
7//! |-- vault.config        encrypted master key + KDF parameters (v2 JSON)
8//! +-- d/
9//!     |-- .manifest.enc   encrypted index of every entry
10//!     +-- <hash>/<hash>.enc  per-entry ciphertext blobs
11//! ```
12//!
13//! # Example
14//!
15//! ```no_run
16//! use krypton::Vault;
17//! use std::path::Path;
18//!
19//! let mut vault = Vault::new("myvault".into());
20//! vault.init("correct horse battery staple").unwrap();
21//! vault.add(Path::new("secret.pdf"), None).unwrap();
22//! let entries = vault.list().unwrap();
23//! vault.extract("secret.pdf", Path::new("./out")).unwrap();
24//! vault.change_password("correct horse battery staple", "new password").unwrap();
25//! vault.lock();
26//! ```
27
28use std::fs;
29use std::io::Write as _;
30use std::path::{Path, PathBuf};
31
32use crate::crypto::Key;
33use crate::error::{Error, Result};
34use crate::sanitize;
35
36pub(crate) mod file_ops;
37pub(crate) mod keystore;
38pub(crate) mod manifest;
39
40use file_ops as blobs;
41use keystore::{KeyStore, VaultConfig};
42use manifest::{EntryMetadata, ManifestMap};
43
44/// A handle to an on-disk encrypted vault.
45///
46/// Operations follow an explicit lock/unlock protocol: [`Vault::unlock`]
47/// derives the master key into zeroizing memory, every content operation
48/// requires the unlocked state, and [`Vault::lock`] (plus `Drop`) scrubs it.
49///
50/// # Example
51///
52/// ```no_run
53/// use krypton::Vault;
54/// use std::path::Path;
55///
56/// let mut vault = Vault::new("myvault".into());
57///
58/// // First run: create the vault (fails if it already exists).
59/// if !vault.exists() {
60///     vault.init("correct horse battery staple")?;
61/// }
62/// vault.unlock("correct horse battery staple")?;
63///
64/// // Store a file and a whole directory tree.
65/// vault.add(Path::new("report.pdf"), None)?;          // stored as "report.pdf"
66/// vault.add(Path::new("photos"), Some("pictures"))?;  // tree renamed to "pictures"
67///
68/// for entry in vault.list()? {
69///     println!("{} — {} bytes, dir: {}", entry.name, entry.size, entry.is_directory);
70/// }
71///
72/// // Restore later; returns where the data was written.
73/// let where_ = vault.extract("report.pdf", Path::new("./out"))?;
74///
75/// // Rotate the password without touching encrypted data.
76/// vault.change_password("correct horse battery staple", "new secret")?;
77///
78/// vault.lock();
79/// # Ok::<(), krypton::Error>(())
80/// ```
81pub struct Vault {
82    path: PathBuf,
83    keystore: KeyStore,
84}
85
86impl std::fmt::Debug for Vault {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("Vault")
89            .field("path", &self.path)
90            .field("unlocked", &self.keystore.is_unlocked())
91            .finish_non_exhaustive()
92    }
93}
94
95/// One entry as returned by [`Vault::list`].
96#[derive(Debug, Clone)]
97pub struct EntryInfo {
98    /// Full vault-relative path.
99    pub name: String,
100    /// Original size in bytes.
101    pub size: u64,
102    /// Directory placeholder or file.
103    pub is_directory: bool,
104}
105
106/// Result of a full-integrity check.
107#[derive(Debug, Default, Clone)]
108pub struct IntegrityReport {
109    /// Entries recorded in the manifest.
110    pub total_entries: usize,
111    /// Blobs that authenticated successfully.
112    pub verified: usize,
113    /// Manifest entries whose blob file is missing.
114    pub missing: Vec<String>,
115    /// Blobs present but failing authentication.
116    pub corrupted: Vec<String>,
117}
118
119impl Vault {
120    /// Creates an unattached handle for the vault at `path`.
121    pub fn new(path: PathBuf) -> Self {
122        Self {
123            path,
124            keystore: KeyStore::new(),
125        }
126    }
127
128    /// Whether this path looks like an initialized vault.
129    pub fn exists(&self) -> bool {
130        self.config_path().exists()
131    }
132
133    /// The vault's base directory.
134    pub fn path(&self) -> &Path {
135        &self.path
136    }
137
138    /// Whether the master key is currently held in memory.
139    pub fn is_unlocked(&self) -> bool {
140        self.keystore.is_unlocked()
141    }
142
143    fn config_path(&self) -> PathBuf {
144        self.path.join("vault.config")
145    }
146
147    fn load_config(&self) -> Result<VaultConfig> {
148        let json = fs::read_to_string(self.config_path())?;
149        VaultConfig::parse(&json)
150    }
151
152    /// Initializes a new vault protected by `password`.
153    ///
154    /// Fails if the vault already exists. Created directories get owner-only
155    /// permissions on Unix; the config is written atomically.
156    ///
157    /// ```no_run
158    /// let mut vault = krypton::Vault::new("secrets".into());
159    /// vault.init("correct horse")?;
160    /// # Ok::<(), krypton::Error>(())
161    /// ```
162    pub fn init(&mut self, password: &str) -> Result<()> {
163        if self.exists() {
164            return Err(Error::VaultExists);
165        }
166
167        crate::fsutil::create_private_dir(&self.path)?;
168        crate::fsutil::create_private_dir(&self.path.join("d"))?;
169
170        let config = self.keystore.init(password)?;
171
172        let json = serde_json::to_string_pretty(&config).map_err(|_| Error::InvalidVault)?;
173        crate::fsutil::atomic_write(&self.config_path(), json.as_bytes())?;
174
175        manifest::save(&self.path, &ManifestMap::new(), self.keystore.master_key()?)?;
176        Ok(())
177    }
178
179    /// Unlocks the vault with `password`.
180    ///
181    /// Authentication failure reports [`Error::Authentication`] without
182    /// distinguishing wrong password from tampered config.
183    pub fn unlock(&mut self, password: &str) -> Result<()> {
184        if !self.exists() {
185            return Err(Error::VaultNotFound);
186        }
187        let config = self.load_config()?;
188        self.keystore.unlock(password, &config)
189    }
190
191    /// Scrubs the master key from memory.
192    pub fn lock(&mut self) {
193        self.keystore.lock();
194    }
195
196    fn require_unlocked(&self) -> Result<Key> {
197        Ok(self.keystore.master_key()?.clone())
198    }
199
200    /// Adds a file or directory tree to the vault.
201    ///
202    /// * `source` - filesystem path to add (symlinks inside trees are
203    ///   skipped).
204    /// * `name` - explicit vault name; defaults to the source basename. Must
205    ///   not contain path separators or `..`.
206    ///
207    /// Adding a directory stores every child recursively; each child gets its
208    /// own independently authenticated blob. An entry with the same name must
209    /// not already exist (remove it first to replace). The manifest is
210    /// committed last and atomically, so a crash can leave orphaned blobs but
211    /// never a dangling index.
212    ///
213    /// ```no_run
214    /// let mut vault = krypton::Vault::new("secrets".into());
215    /// vault.unlock("correct horse")?;
216    ///
217    /// vault.add(std::path::Path::new("notes.txt"), None)?;
218    /// vault.add(std::path::Path::new("~/docs"), Some("docs"))?; // whole tree
219    /// # Ok::<(), krypton::Error>(())
220    /// ```
221    pub fn add(&mut self, source: &Path, name: Option<&str>) -> Result<()> {
222        let master = self.require_unlocked()?;
223
224        let source = source
225            .canonicalize()
226            .map_err(|_| Error::invalid_name(source.display()))?;
227        if !source.is_file() && !source.is_dir() {
228            return Err(Error::invalid_name(source.display()));
229        }
230
231        let root_name = match name {
232            Some(n) => n.to_string(),
233            None => source
234                .file_name()
235                .map(|n| n.to_string_lossy().to_string())
236                .ok_or_else(|| Error::invalid_name(source.display()))?,
237        };
238        sanitize::validate_new_name(&root_name)?;
239
240        let mut all = manifest::load(&self.path, &master)?;
241        if all.contains_key(&root_name) {
242            return Err(Error::EntryExists(root_name));
243        }
244
245        // Phase 1: write every blob (index updated last, so a crash leaves at
246        // worst invisible orphans).
247        if source.is_dir() {
248            add_directory_tree(&self.path, &master, &source, &root_name, &mut all)?;
249        } else {
250            let meta = EntryMetadata {
251                original_name: root_name.clone(),
252                original_size: fs::metadata(&source)?.len(),
253                is_directory: false,
254                children: None,
255            };
256            blobs::write_entry(&self.path, &master, &root_name, &meta, Some(&source))?;
257            all.insert(root_name.clone(), meta);
258        }
259
260        // Phase 2: commit index atomically.
261        manifest::save(&self.path, &all, &master)?;
262        Ok(())
263    }
264
265    /// Removes an entry (and, for directories, its whole subtree).
266    ///
267    /// Both the ciphertext blobs and their manifest records are deleted.
268    pub fn remove(&mut self, name: &str) -> Result<()> {
269        let master = self.require_unlocked()?;
270        sanitize::validate_new_name(name)?;
271
272        let mut all = manifest::load(&self.path, &master)?;
273        if !all.contains_key(name) {
274            return Err(Error::EntryNotFound(preview(name)));
275        }
276
277        remove_subtree(&self.path, &master, name, &mut all);
278        manifest::save(&self.path, &all, &master)?;
279        Ok(())
280    }
281
282    /// Lists top-level entries. Children of directories are addressed by
283    /// their full `parent/child` path in other operations.
284    pub fn list(&self) -> Result<Vec<EntryInfo>> {
285        let master = self.require_unlocked()?;
286        let all = manifest::load(&self.path, &master)?;
287        Ok(all
288            .iter()
289            .filter(|(k, _)| !k.contains('/'))
290            .map(|(k, m)| EntryInfo {
291                name: k.clone(),
292                size: m.original_size,
293                is_directory: m.is_directory,
294            })
295            .collect())
296    }
297
298    /// Extracts an entry.
299    ///
300    /// * Files are written to `dest` (temp file + rename).
301    /// * Directories are recreated under `dest` with their full subtree.
302    ///
303    /// Returns the output location actually used. Child names recovered from
304    /// the vault are sanitized before being joined onto `dest`; any traversal
305    /// attempt aborts the operation.
306    ///
307    /// ```no_run
308    /// let vault = krypton::Vault::new("secrets".into());
309    /// let written = vault.extract("docs", std::path::Path::new("./restored"))?;
310    /// println!("restored to {}", written.display());
311    /// # Ok::<(), krypton::Error>(())
312    /// ```
313    pub fn extract(&self, name: &str, dest: &Path) -> Result<PathBuf> {
314        let master = self.require_unlocked()?;
315
316        let all = manifest::load(&self.path, &master)?;
317        let meta = all
318            .get(name)
319            .ok_or_else(|| Error::EntryNotFound(preview(name)))?
320            .clone();
321
322        if !meta.is_directory {
323            stream_blob_to_file(&self.path, &master, name, dest, false)?;
324            return Ok(dest.to_path_buf());
325        }
326
327        crate::fsutil::create_private_dir(dest)?;
328        let prefix = format!("{name}/");
329        for (child_path, child_meta) in all.range(prefix.clone()..) {
330            if !child_path.starts_with(&prefix) {
331                break;
332            }
333            let rel = &child_path[prefix.len()..];
334            if rel.is_empty() {
335                continue;
336            }
337            let target = join_sanitized(dest, rel)?;
338            if child_meta.is_directory {
339                crate::fsutil::create_private_dir(&target)?;
340            } else {
341                stream_blob_to_file(&self.path, &master, child_path, &target, false)?;
342            }
343        }
344        Ok(dest.to_path_buf())
345    }
346
347    /// Changes the vault password after verifying `old_password`.
348    ///
349    /// Only the wrapped master key is re-encrypted; stored data is untouched.
350    /// Works regardless of lock state and leaves the lock state unchanged.
351    ///
352    /// ```no_run
353    /// let mut vault = krypton::Vault::new("secrets".into());
354    /// vault.change_password("old horse", "new horse")?;
355    /// # Ok::<(), krypton::Error>(())
356    /// ```
357    pub fn change_password(&mut self, old_password: &str, new_password: &str) -> Result<()> {
358        let config = self.load_config()?;
359        // Prove knowledge of the old password even if the vault was already
360        // unlocked.
361        config.unwrap_master_key(old_password)?;
362
363        if !self.keystore.is_unlocked() {
364            self.unlock(old_password)?;
365        }
366
367        let new_config = self.keystore.rotate_password(new_password)?;
368        let json = serde_json::to_string_pretty(&new_config).map_err(|_| Error::InvalidVault)?;
369        crate::fsutil::atomic_write(&self.config_path(), json.as_bytes())?;
370        Ok(())
371    }
372
373    /// Verifies every entry's blob by fully decrypting it (authenticating
374    /// all chunk tags), reporting missing or corrupted items.
375    ///
376    /// Restores the prior lock state when done.
377    ///
378    /// ```no_run
379    /// let mut vault = krypton::Vault::new("secrets".into());
380    /// let report = vault.verify("correct horse")?;
381    /// if report.missing.is_empty() && report.corrupted.is_empty() {
382    ///     println!("all {} entries authenticated", report.total_entries);
383    /// } else {
384    ///     eprintln!("missing: {:?}, corrupted: {:?}", report.missing, report.corrupted);
385    /// }
386    /// # Ok::<(), krypton::Error>(())
387    /// ```
388    pub fn verify(&mut self, password: &str) -> Result<IntegrityReport> {
389        let was_unlocked = self.is_unlocked();
390        if !was_unlocked {
391            self.unlock(password)?;
392        }
393        let result = self.verify_unlocked();
394        if !was_unlocked {
395            self.lock();
396        }
397        result
398    }
399
400    fn verify_unlocked(&mut self) -> Result<IntegrityReport> {
401        let master = self.require_unlocked()?;
402        let all = manifest::load(&self.path, &master)?;
403
404        let mut report = IntegrityReport {
405            total_entries: all.len(),
406            ..IntegrityReport::default()
407        };
408
409        for (name, meta) in all.iter() {
410            match blobs::read_entry(&self.path, &master, name, meta.is_directory, |_| Ok(())) {
411                Ok(_) => report.verified += 1,
412                Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
413                    report.missing.push(name.clone());
414                }
415                Err(_) => report.corrupted.push(name.clone()),
416            }
417        }
418
419        Ok(report)
420    }
421}
422
423impl Drop for Vault {
424    fn drop(&mut self) {
425        self.keystore.lock();
426    }
427}
428
429/// Adds a directory tree: one blob per file plus directory placeholders,
430/// with manifest child lists wired up for recursive removal.
431fn add_directory_tree(
432    vault_dir: &Path,
433    master: &Key,
434    source_root: &Path,
435    vault_name: &str,
436    all: &mut ManifestMap,
437) -> Result<()> {
438    use walkdir::WalkDir;
439
440    // Insert the root placeholder first so children can register with it.
441    all.insert(
442        vault_name.to_string(),
443        EntryMetadata {
444            original_name: vault_name.to_string(),
445            original_size: 0,
446            is_directory: true,
447            children: Some(Vec::new()),
448        },
449    );
450    let mut dir_paths: Vec<String> = vec![vault_name.to_string()];
451
452    let entries: Vec<_> = WalkDir::new(source_root)
453        .sort_by_file_name()
454        .into_iter()
455        .collect::<std::result::Result<Vec<_>, _>>()
456        .map_err(|_| Error::invalid_name(source_root.display()))?;
457
458    // WalkDir yields parents before children, so each entry's parent record
459    // already exists here.
460    for entry in entries {
461        let ft = entry.file_type();
462        if ft.is_symlink() {
463            continue;
464        }
465        let rel = entry
466            .path()
467            .strip_prefix(source_root)
468            .map_err(|_| Error::invalid_name(entry.path().display()))?
469            .to_string_lossy()
470            .to_string();
471        if rel.is_empty() {
472            continue;
473        }
474        sanitize::validate_new_name(&rel)?;
475
476        let full = format!("{vault_name}/{rel}");
477        let parent_full = full
478            .rsplit_once('/')
479            .map(|(p, _)| p.to_string())
480            .ok_or(Error::InvalidVault)?;
481
482        if ft.is_dir() {
483            all.insert(
484                full.clone(),
485                EntryMetadata {
486                    original_name: full.clone(),
487                    original_size: 0,
488                    is_directory: true,
489                    children: Some(Vec::new()),
490                },
491            );
492            dir_paths.push(full.clone());
493        } else if ft.is_file() {
494            let meta = EntryMetadata {
495                original_name: full.clone(),
496                original_size: entry.metadata().map(|m| m.len()).unwrap_or(0),
497                is_directory: false,
498                children: None,
499            };
500            blobs::write_entry(vault_dir, master, &full, &meta, Some(entry.path()))?;
501            all.insert(full.clone(), meta);
502        }
503
504        if let Some(pmeta) = all.get_mut(&parent_full) {
505            if let Some(children) = pmeta.children.as_mut() {
506                children.push(full);
507            }
508        }
509    }
510
511    // Persist directory placeholder blobs (only the ones from this call).
512    for dir_path in &dir_paths {
513        if let Some(meta) = all.get(dir_path) {
514            blobs::write_entry(vault_dir, master, dir_path, meta, None)?;
515        }
516    }
517    Ok(())
518}
519
520/// Removes an entry subtree: blobs first, index records second.
521fn remove_subtree(vault_dir: &Path, master: &Key, name: &str, all: &mut ManifestMap) {
522    let mut stack = vec![name.to_string()];
523    let mut doomed = Vec::new();
524    while let Some(cur) = stack.pop() {
525        if let Some(meta) = all.get(&cur) {
526            if let Some(children) = &meta.children {
527                stack.extend(children.iter().cloned());
528            }
529        }
530        doomed.push(cur);
531    }
532    for cur in doomed {
533        let _ = blobs::remove_blob(vault_dir, master, &cur);
534        all.remove(&cur);
535    }
536}
537
538/// Joins a stored relative name onto `base`, refusing traversal attempts.
539fn join_sanitized(base: &Path, rel: &str) -> Result<PathBuf> {
540    sanitize::sanitize_stored_name(rel)?;
541    Ok(base.join(rel))
542}
543
544/// Streams one blob's plaintext through a temp file onto `target`.
545fn stream_blob_to_file(
546    vault_dir: &Path,
547    master: &Key,
548    entry_path: &str,
549    target: &Path,
550    is_directory: bool,
551) -> Result<()> {
552    let tmp = crate::fsutil::sibling_temp_path(target);
553
554    let outcome = (|| -> Result<()> {
555        {
556            let f = fs::File::create(&tmp)?;
557            crate::fsutil::restrict_perms(&tmp);
558            let mut w = std::io::BufWriter::new(f);
559            blobs::read_entry(vault_dir, master, entry_path, is_directory, |chunk| {
560                w.write_all(chunk)?;
561                Ok(())
562            })?;
563            w.flush()?;
564            w.get_ref().sync_all()?;
565        }
566        #[cfg(windows)]
567        if target.exists() {
568            fs::remove_file(target)?;
569        }
570        fs::rename(&tmp, target)?;
571        crate::fsutil::sync_dir(target.parent().unwrap_or_else(|| Path::new(".")));
572        Ok(())
573    })();
574
575    match outcome {
576        Ok(()) => Ok(()),
577        Err(e) => {
578            let _ = fs::remove_file(&tmp);
579            Err(e)
580        }
581    }
582}
583
584fn preview(name: &str) -> String {
585    name.chars().take(64).collect()
586}