Skip to main content

trove_core/
lib.rs

1//! `trove-core` — kdbx I/O and vault primitives.
2//!
3//! Format compatibility with KeePassXC is non-negotiable: this crate must
4//! round-trip any valid `.kdbx` file. v0.0.1 scope is KDBX 4 with a password
5//! master key only; keyfiles, hardware tokens, and KDBX 3 land later.
6//!
7//! As of v0.0.10, trove-core depends on the published `keepass = "0.12"` crate
8//! directly — no more vendored fork. The earlier vendored 0.7.33 + three
9//! binary-attachment patches is gone; upstream's PR #294 already restructured
10//! attachments as first-class Database-owned objects, and the new
11//! `EntryMut::add_attachment(name, Value::Unprotected(bytes))` /
12//! `EntryRef::attachment_by_name(name)` pair does what we need without any
13//! local patches. The `_SDPM_BIN_*` Protected-string fallback that v0.0.4
14//! introduced for backwards compat is also gone, since no v0.0.1–0.0.3.x
15//! production vaults exist (the project hadn't shipped yet).
16
17#![forbid(unsafe_code)]
18
19use std::path::{Path, PathBuf};
20
21use keepass::config::DatabaseVersion;
22use keepass::db::Value;
23use zeroize::Zeroize;
24
25mod error;
26pub use error::Error;
27
28pub type Result<T> = std::result::Result<T, Error>;
29
30/// Name of the database's single top-level group. KeePassXC names it "Root";
31/// keepass-rs leaves it empty, which surfaces as a nameless folder in other
32/// clients. trove names it on save and treats it as the implicit home for
33/// entries added without a group prefix — so a leading `Root/` segment in a
34/// path denotes this same group rather than a child of it.
35const DEFAULT_GROUP: &str = "Root";
36
37/// Stable identifier for an entry within a vault.
38///
39/// Backed by the kdbx UUID, serialised as a string for wire/disk transport.
40/// We keep our own newtype rather than re-exporting `keepass::db::EntryId`
41/// because (a) the upstream type's constructors are `pub(crate)` so we can't
42/// build one from a Uuid externally anyway, and (b) the daemon control protocol
43/// already serialises entry IDs as JSON strings.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct EntryId(pub(crate) String);
46
47impl EntryId {
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53impl std::fmt::Display for EntryId {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(&self.0)
56    }
57}
58
59impl std::str::FromStr for EntryId {
60    type Err = std::convert::Infallible;
61    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
62        Ok(EntryId(s.to_string()))
63    }
64}
65
66/// Non-secret summary of an entry. Suitable for listing without unlocking secrets.
67#[derive(Debug, Clone)]
68pub struct EntrySummary {
69    pub id: EntryId,
70    pub title: String,
71    pub username: Option<String>,
72    pub url: Option<String>,
73    pub attachment_names: Vec<String>,
74    /// Names of the groups containing this entry, root → leaf. Root group
75    /// itself is excluded (an entry directly under root has an empty
76    /// `group_path`). Use `display_path()` to render as `Group/Sub/Title`.
77    pub group_path: Vec<String>,
78}
79
80impl EntrySummary {
81    /// Format the full path as `Group/Sub/.../Title`. Falls back to just
82    /// the title when the entry lives at the root.
83    pub fn display_path(&self) -> String {
84        if self.group_path.is_empty() {
85            self.title.clone()
86        } else {
87            let mut s = self.group_path.join("/");
88            s.push('/');
89            s.push_str(&self.title);
90            s
91        }
92    }
93}
94
95/// An open, in-memory vault.
96///
97/// Dropping the value drops the underlying decrypted material. Best-effort
98/// memory zeroing is delegated to the `keepass` crate where supported.
99pub struct Vault {
100    pub(crate) inner: VaultInner,
101}
102
103pub(crate) struct VaultInner {
104    pub(crate) path: PathBuf,
105    pub(crate) password: String,
106    pub(crate) db: keepass::Database,
107}
108
109impl Drop for VaultInner {
110    fn drop(&mut self) {
111        // Best-effort: wipe the password material we kept in memory.
112        // The `keepass::Database` carries its own SecretBox-backed protected
113        // values; we don't reach into it.
114        self.password.zeroize();
115    }
116}
117
118impl Vault {
119    /// Create a new kdbx file at `path`, encrypted with `password`.
120    /// Errors if the file already exists.
121    pub fn create(path: &Path, password: &str) -> Result<Self> {
122        if path.exists() {
123            return Err(Error::AlreadyExists(path.to_path_buf()));
124        }
125
126        // `Database::new()` uses the default DatabaseConfig: KDBX4 + AES-256
127        // + GZip + ChaCha20 (inner stream) + Argon2d. KeePassXC reads this fine.
128        let db = keepass::Database::new();
129
130        let mut vault = Vault {
131            inner: VaultInner {
132                path: path.to_path_buf(),
133                password: password.to_string(),
134                db,
135            },
136        };
137        vault.save()?;
138        Ok(vault)
139    }
140
141    /// Open an existing kdbx file with a password.
142    pub fn open(path: &Path, password: &str) -> Result<Self> {
143        if !path.exists() {
144            return Err(Error::NotFound(path.to_path_buf()));
145        }
146        let mut file = std::fs::File::open(path)?;
147        let key = keepass::DatabaseKey::new().with_password(password);
148        let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
149        Ok(Vault {
150            inner: VaultInner {
151                path: path.to_path_buf(),
152                password: password.to_string(),
153                db,
154            },
155        })
156    }
157
158    /// Persist in-memory state back to the original path (atomic replace).
159    pub fn save(&mut self) -> Result<()> {
160        // trove only ever writes KDBX 4.1. Force the version before serializing
161        // so re-saving a legacy 4.0 vault (written by keepass 0.12.5) succeeds:
162        // the 0.13.10 writer emits only 4.1 and would otherwise reject KDB4(0)
163        // with "Unsupported database version". The re-serialize also drops
164        // 0.12.5's empty numeric <Meta> elements that made KeePassXC reject the
165        // file with "Invalid number value".
166        self.inner.db.config.version = DatabaseVersion::KDB4(1);
167        // Pin the optional <Meta> policy fields to KeePassXC's own defaults so a
168        // trove vault behaves identically in any reader. Backfill-only — a value
169        // already set (by KeePassXC, or a future trove setting) is left as-is.
170        apply_default_meta_policy(&mut self.inner.db.meta);
171        // Give the top-level group a name if it has none, so other clients
172        // (KeePassXC et al.) show a proper "Root" folder instead of a blank
173        // one. Backfills freshly created vaults (create() calls save()) and
174        // any legacy vault on its next write. trove addresses entries by the
175        // group chain *below* the root (`build_group_path` excludes it
176        // structurally), so naming it is invisible to our own paths.
177        if self.inner.db.root().name.is_empty() {
178            self.inner
179                .db
180                .root_mut()
181                .edit(|g| g.name = DEFAULT_GROUP.to_string());
182        }
183
184        let dir = self
185            .inner
186            .path
187            .parent()
188            .filter(|p| !p.as_os_str().is_empty())
189            .map(Path::to_path_buf)
190            .unwrap_or_else(|| PathBuf::from("."));
191
192        let file_name = self
193            .inner
194            .path
195            .file_name()
196            .ok_or_else(|| {
197                Error::Io(std::io::Error::new(
198                    std::io::ErrorKind::InvalidInput,
199                    "vault path has no file name",
200                ))
201            })?
202            .to_owned();
203
204        let mut tmp_name = std::ffi::OsString::from(&file_name);
205        tmp_name.push(format!(".tmp.{}", std::process::id()));
206        let tmp_path = dir.join(&tmp_name);
207
208        // Scope the file handle so it is closed (and thus fully flushed by the
209        // OS) before we attempt the rename. We also fsync explicitly for
210        // crash-safety on POSIX.
211        {
212            let mut tmp = std::fs::File::create(&tmp_path)?;
213            let key = keepass::DatabaseKey::new().with_password(&self.inner.password);
214            self.inner
215                .db
216                .save(&mut tmp, key)
217                .map_err(save_err_to_error)?;
218            tmp.sync_all()?;
219        }
220
221        // Atomic replace. `rename` over an existing target is atomic on POSIX.
222        if let Err(e) = std::fs::rename(&tmp_path, &self.inner.path) {
223            let _ = std::fs::remove_file(&tmp_path);
224            return Err(Error::Io(e));
225        }
226
227        Ok(())
228    }
229
230    pub fn path(&self) -> &Path {
231        &self.inner.path
232    }
233
234    /// Add a new entry. The `title` is interpreted as a `/`-separated path:
235    /// the leading segments name a group hierarchy (created as needed,
236    /// `mkdir -p` semantics), and the trailing segment becomes the entry
237    /// title. A title with no `/` lands at the root group, matching the
238    /// previous behavior.
239    ///
240    /// A leading `Root` segment (case-insensitive) names the root group
241    /// itself, so `add_entry("Root/github")` is identical to `add_entry("github")`.
242    ///
243    /// Examples:
244    ///   * `add_entry("github")`            → "github" in the root group
245    ///   * `add_entry("Work/SSH/github")`   → group "Work" > "SSH", entry "github"
246    ///
247    /// Empty segments (`//`, `/foo`, `foo/`) and the empty title are rejected
248    /// with `Error::InvalidPath`. Group lookups are case-insensitive (matches
249    /// keepass-rs and KeePassXC behavior), so `work/ssh` resolves to an
250    /// existing `Work/SSH`. Returns the entry's stable ID.
251    pub fn add_entry(&mut self, title: &str) -> Result<EntryId> {
252        let (group_path, leaf) = parse_entry_path(title)?;
253        // Walk by GroupId rather than by mutable reference — we can't carry a
254        // GroupMut across the loop because each iteration's lookup re-borrows
255        // through the previous one.
256        let mut current_id = self.inner.db.root().id();
257        for segment in &group_path {
258            let mut current = self
259                .inner
260                .db
261                .group_mut(current_id)
262                .expect("walked GroupId always resolves");
263            let existing = current.group_by_name_mut(segment).map(|g| g.id());
264            let next_id = match existing {
265                Some(id) => id,
266                None => current.add_group().edit(|g| g.name = segment.clone()).id(),
267            };
268            current_id = next_id;
269        }
270        let mut leaf_group = self
271            .inner
272            .db
273            .group_mut(current_id)
274            .expect("leaf GroupId always resolves");
275        let mut entry = leaf_group.add_entry();
276        entry.set_unprotected("Title", &leaf);
277        Ok(EntryId(entry.id().uuid().to_string()))
278    }
279
280    /// List all entries in the vault (recursively across all groups).
281    pub fn list_entries(&self) -> Vec<EntrySummary> {
282        self.inner
283            .db
284            .iter_all_entries()
285            .map(|e| summarise(&e))
286            .collect()
287    }
288
289    /// Look up an entry by ID. Returns `None` if no such entry exists.
290    pub fn get_entry(&self, id: &EntryId) -> Option<EntrySummary> {
291        self.inner
292            .db
293            .iter_all_entries()
294            .find(|e| e.id().uuid().to_string() == id.0)
295            .map(|e| summarise(&e))
296    }
297
298    /// Look up an entry by title or path.
299    ///
300    /// * Plain title with no `/`: returns the first entry whose leaf title
301    ///   matches (current behavior). Search is exact (case-sensitive) on the
302    ///   leaf title across all groups.
303    /// * Path with `/`: navigates `group/sub/.../leaf` and matches only the
304    ///   entry at exactly that path. Group navigation is case-insensitive
305    ///   (matching keepass-rs); the leaf title comparison is exact.
306    ///
307    /// Returns `None` if no such entry exists, or if any group segment in
308    /// the path is missing.
309    pub fn find_by_title(&self, title: &str) -> Option<EntryId> {
310        if title.contains('/') {
311            let (group_path, leaf) = parse_entry_path(title).ok()?;
312            // `title.contains('/')` guarantees at least one group segment.
313            let segs: Vec<&str> = group_path.iter().map(String::as_str).collect();
314            let root = self.inner.db.root();
315            let group = root.group_by_path(&segs)?;
316            return group
317                .entries()
318                .find(|e| e.get_title() == Some(leaf.as_str()))
319                .map(|e| EntryId(e.id().uuid().to_string()));
320        }
321        self.inner
322            .db
323            .iter_all_entries()
324            .find(|e| e.get_title() == Some(title))
325            .map(|e| EntryId(e.id().uuid().to_string()))
326    }
327
328    /// Set or replace a string field on an entry. Standard fields:
329    /// `"Title"`, `"UserName"`, `"Password"`, `"URL"`, `"Notes"`. Custom fields permitted.
330    pub fn set_field(&mut self, id: &EntryId, field: &str, value: &str) -> Result<()> {
331        let entry_id = self.lookup_entry_id(id)?;
332        let mut entry = self
333            .inner
334            .db
335            .entry_mut(entry_id)
336            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
337        if field == "Password" {
338            entry.set_protected(field, value);
339        } else {
340            entry.set_unprotected(field, value);
341        }
342        Ok(())
343    }
344
345    /// Attach a binary blob (e.g. an SSH private key) to an entry under `name`.
346    /// Replaces any existing attachment with the same name.
347    ///
348    /// Bytes are stored as a real KDBX4 inner-header binary attachment with a
349    /// `<Binary Ref="N"/>` reference inside the entry, matching what KeePassXC
350    /// writes. The Protected flag is left at the default (off) — KeePassXC
351    /// likewise stores SSH private keys without it.
352    pub fn attach_binary(&mut self, id: &EntryId, name: &str, bytes: &[u8]) -> Result<()> {
353        let entry_id = self.lookup_entry_id(id)?;
354        let mut entry = self
355            .inner
356            .db
357            .entry_mut(entry_id)
358            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
359        // Replace-by-name semantics: drop any existing attachment with the
360        // same name first. add_attachment doesn't dedupe, so without this
361        // we'd accumulate orphans on rewrites.
362        entry.remove_attachment_by_name(name);
363        entry.add_attachment(name, Value::Unprotected(bytes.to_vec()));
364        Ok(())
365    }
366
367    /// Read an attachment's bytes. Returns `Ok(None)` if the entry exists but has no such attachment.
368    /// Errors if the entry itself does not exist.
369    pub fn read_binary(&self, id: &EntryId, name: &str) -> Result<Option<Vec<u8>>> {
370        let entry_id = self.lookup_entry_id(id)?;
371        let entry = self
372            .inner
373            .db
374            .entry(entry_id)
375            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
376        // `Value::get()` returns the inner bytes whether the value is stored
377        // unprotected or protected (it transparently exposes the secret), so we
378        // no longer need to match the variant or depend on `secrecy`.
379        Ok(entry
380            .attachment_by_name(name)
381            .map(|att| att.data.get().clone()))
382    }
383
384    /// Remove an attachment from an entry. No-op if the attachment is missing.
385    pub fn remove_binary(&mut self, id: &EntryId, name: &str) -> Result<()> {
386        let entry_id = self.lookup_entry_id(id)?;
387        let mut entry = self
388            .inner
389            .db
390            .entry_mut(entry_id)
391            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
392        entry.remove_attachment_by_name(name);
393        Ok(())
394    }
395
396    /// Delete an entry by ID.
397    pub fn delete_entry(&mut self, id: &EntryId) -> Result<()> {
398        let entry_id = self.lookup_entry_id(id)?;
399        let entry = self
400            .inner
401            .db
402            .entry_mut(entry_id)
403            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
404        entry.remove();
405        Ok(())
406    }
407
408    /// Read a single string field from an entry. Returns `None` if the field
409    /// is missing. Errors if the entry itself does not exist.
410    ///
411    /// Used by the materialization layer to read `Materialize.*` custom fields
412    /// from entries that opt in.
413    pub fn get_field(&self, id: &EntryId, field: &str) -> Result<Option<String>> {
414        let entry_id = self.lookup_entry_id(id)?;
415        let entry = self
416            .inner
417            .db
418            .entry(entry_id)
419            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
420        Ok(entry.get(field).map(|s| s.to_string()))
421    }
422
423    /// Return the names of every custom string field on an entry whose name
424    /// starts with `prefix`. Field names are returned in unspecified order.
425    /// Errors if the entry does not exist.
426    ///
427    /// Used by the materialization layer so the daemon can quickly tell which
428    /// entries opt in (any entry with at least one `Materialize.*` field).
429    pub fn fields_with_prefix(&self, id: &EntryId, prefix: &str) -> Result<Vec<String>> {
430        let entry_id = self.lookup_entry_id(id)?;
431        let entry = self
432            .inner
433            .db
434            .entry(entry_id)
435            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
436        Ok(entry
437            .fields
438            .keys()
439            .filter(|k| k.starts_with(prefix))
440            .cloned()
441            .collect())
442    }
443
444    /// Convert our `EntryId(String)` into the upstream `keepass::db::EntryId`
445    /// by walking entries and matching on Uuid string. Upstream's EntryId has
446    /// only `pub(crate)` constructors, so this is the only way to round-trip.
447    fn lookup_entry_id(&self, id: &EntryId) -> Result<keepass::db::EntryId> {
448        self.inner
449            .db
450            .iter_all_entries()
451            .find(|e| e.id().uuid().to_string() == id.0)
452            .map(|e| e.id())
453            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))
454    }
455}
456
457// --- helpers ---------------------------------------------------------------
458
459fn summarise(e: &keepass::db::EntryRef<'_>) -> EntrySummary {
460    let attachment_names: Vec<String> = e
461        .attachments_named()
462        .map(|(name, _)| name.to_string())
463        .collect();
464    EntrySummary {
465        id: EntryId(e.id().uuid().to_string()),
466        title: e.get_title().unwrap_or("").to_string(),
467        username: e.get_username().map(str::to_owned),
468        url: e.get_url().map(str::to_owned),
469        attachment_names,
470        group_path: build_group_path(e),
471    }
472}
473
474/// Walk an entry's parent chain to the database root, collecting group
475/// names. The root group is excluded — entries directly under root return
476/// an empty vec. Output is ordered root → leaf so it joins as a path.
477///
478/// Walks by `GroupId` rather than `GroupRef` because the borrow checker
479/// can't see that `cur.parent()` and `cur = parent` use disjoint slots of
480/// the same `&Database`.
481fn build_group_path(e: &keepass::db::EntryRef<'_>) -> Vec<String> {
482    let db = e.database();
483    let mut rev: Vec<String> = Vec::new();
484    let mut cur_id = e.parent().id();
485    while let Some(g) = db.group(cur_id) {
486        match g.parent() {
487            // Not at root yet — record this group's name and step up.
488            Some(parent) => {
489                rev.push(g.name.clone());
490                cur_id = parent.id();
491            }
492            // Reached root (no parent). Root is excluded from the path.
493            None => break,
494        }
495    }
496    rev.reverse();
497    rev
498}
499
500/// Split a `/`-separated entry path into `(group_segments, leaf_title)`.
501/// Returns `Err(Error::InvalidPath)` on any empty segment, empty leaf,
502/// or trailing slash. A path with no `/` returns `(vec![], path)`.
503///
504/// A leading [`DEFAULT_GROUP`] (`"Root"`, case-insensitive) segment is
505/// dropped: it names the database's top-level group, which is where group
506/// walks already start. So `Root/x` and bare `x` resolve to the same place
507/// and we never nest a `Root` inside the root.
508fn parse_entry_path(s: &str) -> Result<(Vec<String>, String)> {
509    if s.is_empty() {
510        return Err(Error::InvalidPath("title must not be empty".into()));
511    }
512    let parts: Vec<&str> = s.split('/').collect();
513    if parts.iter().any(|p| p.is_empty()) {
514        return Err(Error::InvalidPath(format!(
515            "path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
516        )));
517    }
518    let mut iter = parts.into_iter();
519    let last = iter
520        .next_back()
521        .expect("non-empty split always yields at least one element");
522    let mut groups: Vec<String> = iter.map(String::from).collect();
523    if groups
524        .first()
525        .is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
526    {
527        groups.remove(0);
528    }
529    Ok((groups, last.to_string()))
530}
531
532fn open_err_to_error(e: keepass::error::DatabaseOpenError) -> Error {
533    use keepass::error::{DatabaseKeyError, DatabaseOpenError};
534    match e {
535        DatabaseOpenError::Io(io) => Error::Io(io),
536        DatabaseOpenError::Key(DatabaseKeyError::IncorrectKey) => Error::BadPassword,
537        DatabaseOpenError::Key(other) => Error::Kdbx(other.to_string()),
538        DatabaseOpenError::UnsupportedVersion => {
539            Error::Kdbx("unsupported kdbx version".to_string())
540        }
541        // DatabaseOpenError is #[non_exhaustive] in 0.12; integrity errors
542        // (header HMAC mismatch on wrong password, etc.) flow through here.
543        // The crate's PartialEq Debug impl prints "IncorrectKey" for either
544        // path, so a string-match against the rendered error catches them.
545        other => {
546            let msg = other.to_string();
547            if msg.to_lowercase().contains("incorrect")
548                || msg.to_lowercase().contains("header hash")
549            {
550                Error::BadPassword
551            } else {
552                Error::Kdbx(msg)
553            }
554        }
555    }
556}
557
558fn save_err_to_error(e: keepass::error::DatabaseSaveError) -> Error {
559    use keepass::error::DatabaseSaveError;
560    match e {
561        DatabaseSaveError::Io(io) => Error::Io(io),
562        other => Error::Kdbx(other.to_string()),
563    }
564}
565
566/// Backfill the optional `<Meta>` policy fields with KeePassXC's own defaults.
567///
568/// trove never sets these itself, so left alone every reader substitutes its
569/// own defaults and the effective policy depends on whichever tool last wrote
570/// the file. Pinning them to the values `keepassxc-cli db-create` writes makes
571/// a trove vault behave identically anywhere (and keeps the cross-tool
572/// conformance matrix deterministic):
573///   * 365-day maintenance-history window,
574///   * master-key-change recommend/force both off (`-1`, the KeePass
575///     "disabled" sentinel — these are *not* counters),
576///   * 10-item / 6 MiB per-entry history limits,
577///   * recycle bin enabled.
578///
579/// Backfill-only: a field already `Some(_)` is left untouched, so a policy a
580/// user set in KeePassXC survives a trove round-trip.
581fn apply_default_meta_policy(meta: &mut keepass::db::Meta) {
582    meta.maintenance_history_days.get_or_insert(365);
583    meta.master_key_change_rec.get_or_insert(-1);
584    meta.master_key_change_force.get_or_insert(-1);
585    meta.history_max_items.get_or_insert(10);
586    meta.history_max_size.get_or_insert(6 * 1024 * 1024);
587    meta.recyclebin_enabled.get_or_insert(true);
588}