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}