secrets manager
git clone https://git.lucas.co/cce-secrets.git
perf(keyring-sync): a quiet pass reads no keyring secrets
Every pass read each keyring item's attributes, label, secret and modified
time — for 379 items, 1,523 Secret Service calls and 380 decrypted
passwords every 5 minutes, to find nothing changed. The 1Password side
already skips entries whose updated_at matches the base; the keyring side
now does the same by Modified, which gnome-keyring bumps on a label,
attribute or secret edit and leaves alone on a read (probed in an
isolated keyring). An item still at the base's time is read for its
attributes and time only; its hash is the base's.
- Plans that copy keyring fields out (to 1Password, keyring-wins
conflicts, re-creates) read the item first; a failed read still stops
the pass. A zero time is never trusted.
- An item read but unchanged (touched, same value) records its new time
on the base, so the next pass skips it.
Isolated keyring + fake op, 379 items: a quiet pass 1,523 -> 765 calls,
380 -> 1 GetSecret, keyring CPU 100 -> 50 ms, wall 155 -> 90 ms. Label,
secret and URL edits each way, a 1Password edit then a quiet pass, a
no-op touch, a delete and a keyring-born item all sync as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KEYRING-SYNC.md | 9 +++
src/bin/cce-keyring-sync/sync.rs | 148 ++++++++++++++++++++++++++++++---------
2 files changed, 124 insertions(+), 33 deletions(-)
diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index 8c53200..7552325 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -280,6 +280,15 @@ goes on argv** (argv is readable by every same-user process via
a quiet run is one `op` process and a few hundred bytes of JSON. That also
keeps the timer's steady state from touching a single secret.
+The keyring side matches it by `Modified`, which gnome-keyring bumps on a
+label, attribute or secret edit and leaves alone on a read (measured
+2026-10-06): an item still at the base's time is read for its attributes
+and time only, never its label or secret. Until then every pass read and
+decrypted all of them — 1,523 Secret Service calls and 380 `GetSecret`s
+for 379 items, measured in an isolated keyring; now 765 calls and one
+`GetSecret` (the state hash key), and gnome-keyring's share of a pass
+halves.
+
## Pairing and field mapping
Same table as the kdbx, with the id attribute renamed:
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
index fbee678..680c90d 100644
--- a/src/bin/cce-keyring-sync/sync.rs
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -9,8 +9,13 @@
//! 1Password records item history on every edit.
//!
//! Change detection on the remote side is by `updated_at`: an entry whose
-//! timestamp still equals the base's is unchanged and never fetched, so a
-//! quiet tick is one `op item list` and no secrets.
+//! timestamp still equals the base's is unchanged and never fetched. The
+//! keyring side does the same by `Modified`, which gnome-keyring bumps on
+//! every label, attribute and secret edit and leaves alone on a read
+//! (measured 2026-10-06): an item whose time still equals the base's is not
+//! read past its attributes. So a quiet tick is one `op item list`, two
+//! keyring calls per item, and no secrets — until 2026-10-06 it read and
+//! decrypted every password (~1,500 calls for 379 items) to find nothing.
use std::collections::HashMap;
@@ -141,7 +146,53 @@ fn keyring_attrs<'a>(k: &'a KrEntry, id: &'a str, extra: &'a HashMap<String, Str
struct Local<'a> {
item: secret_service::Item<'a>,
attrs: HashMap<String, String>,
- entry: KrEntry,
+ modified: u64,
+ /// The item's synced fields, or `None` when its `Modified` still equals
+ /// the base's: unchanged since the last sync, so its hash is the base's
+ /// and its label and secret were never read. Every plan that writes
+ /// keyring fields elsewhere reads them first ([`ensure_read`]).
+ entry: Option<KrEntry>,
+}
+
+/// Whether a keyring item can stand on its base unread: it has one, and its
+/// `Modified` is the one the base recorded. A zero time is never trusted —
+/// it is what a failed read once defaulted to.
+fn unchanged_since_base(base: Option<&EntryState>, modified: u64) -> bool {
+ base.is_some_and(|b| modified != 0 && b.keyring_modified == modified)
+}
+
+/// Read an item's synced fields. Any failure stops the pass (see the
+/// snapshot below): a read that could not be answered never stands in as
+/// an empty value.
+async fn read_entry(
+ item: &secret_service::Item<'_>,
+ attrs: &HashMap<String, String>,
+ modified: u64,
+ vault: &str,
+) -> Result<KrEntry, String> {
+ let unreadable = |what: &str, e: &dyn std::fmt::Display| {
+ let which = attrs.get(OP_ITEM_ATTR).map(String::as_str).unwrap_or("an unpaired item");
+ format!("reading the {what} of {which} failed: {e}; nothing synced this pass")
+ };
+ let title = item.get_label().await.map_err(|e| unreadable("title", &e))?;
+ let secret = item.get_secret().await.map_err(|e| unreadable("password", &e))?;
+ Ok(KrEntry {
+ title,
+ username: attrs.get("UserName").cloned().unwrap_or_default(),
+ password: String::from_utf8_lossy(&secret).into_owned(),
+ url: attrs.get("URL").cloned().unwrap_or_default(),
+ notes: attrs.get("Notes").cloned().unwrap_or_default(),
+ group: attrs.get(OP_VAULT_ATTR).cloned().unwrap_or_else(|| vault.to_string()),
+ modified,
+ })
+}
+
+/// A local item's fields, reading them now if the snapshot skipped them.
+async fn ensure_read(l: &mut Local<'_>, vault: &str) -> Result<(), String> {
+ if l.entry.is_none() {
+ l.entry = Some(read_entry(&l.item, &l.attrs, l.modified, vault).await?);
+ }
+ Ok(())
}
/// One merge pass. Always writes the state file on a real run (with
@@ -217,28 +268,19 @@ pub async fn sync_remote<I: Interchange>(
if id.is_none() && !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
continue; // some other app's item — never ours to sync
}
- let unreadable = |what: &str, e: &dyn std::fmt::Display| {
+ let modified = item.get_modified().await.map_err(|e| {
let which = attrs.get(OP_ITEM_ATTR).map(String::as_str).unwrap_or("an unpaired item");
- format!("reading the {what} of {which} failed: {e}; nothing synced this pass")
- };
- let title = item.get_label().await.map_err(|e| unreadable("title", &e))?;
- let secret = item.get_secret().await.map_err(|e| unreadable("password", &e))?;
- let modified = item.get_modified().await.map_err(|e| unreadable("modified time", &e))?;
- let entry = KrEntry {
- title,
- username: attrs.get("UserName").cloned().unwrap_or_default(),
- password: String::from_utf8_lossy(&secret).into_owned(),
- url: attrs.get("URL").cloned().unwrap_or_default(),
- notes: attrs.get("Notes").cloned().unwrap_or_default(),
- group: attrs.get(OP_VAULT_ATTR).cloned().unwrap_or_else(|| vault.clone()),
- modified,
- };
- let local = Local { item, attrs, entry };
+ format!("reading the modified time of {which} failed: {e}; nothing synced this pass")
+ })?;
+ // Unchanged since the base: the label and the secret are not read.
+ let unchanged = unchanged_since_base(id.as_ref().and_then(|id| state.entries.get(id)), modified);
+ let entry = if unchanged { None } else { Some(read_entry(&item, &attrs, modified, &vault).await?) };
+ let local = Local { item, attrs, modified, entry };
match id {
Some(id) => {
// Two items with one stamp (a tool that re-created rather than
// edited): the newer one is the person's latest word.
- let newer = kr.get(&id).is_none_or(|old| local.entry.modified >= old.entry.modified);
+ let newer = kr.get(&id).is_none_or(|old| local.modified >= old.modified);
if newer {
kr.insert(id, local);
}
@@ -267,7 +309,14 @@ pub async fn sync_remote<I: Interchange>(
let mut plans: Vec<(String, Plan)> = Vec::new();
for id in &ids {
let base = state.entries.get(id);
- let k_side = kr.get(id).map(|l| Side { hash: l.entry.hash(&hash_key), time: l.entry.modified as i64 });
+ let k_side = kr.get(id).map(|l| Side {
+ hash: match &l.entry {
+ Some(e) => e.hash(&hash_key),
+ // Not read: unchanged since the base, which is what it hashed to.
+ None => base.map(|b| b.h.clone()).unwrap_or_default(),
+ },
+ time: l.modified as i64,
+ });
let r_side = match rs.get(id) {
None => None,
Some(s) => {
@@ -286,11 +335,23 @@ pub async fn sync_remote<I: Interchange>(
plans.push((id.clone(), plan(base.map(|b| b.h.as_str()), r_side.as_ref(), k_side.as_ref())));
}
+ // The plans that copy keyring fields out need them read. An item left
+ // unread hashes to its base, so these never pick one — but the fields
+ // are read rather than trusted to that.
+ for (id, p) in &plans {
+ if matches!(p, Plan::ToRemote | Plan::ConflictKeyringWins | Plan::CreateRemote) {
+ if let Some(l) = kr.get_mut(id) {
+ ensure_read(l, &vault).await?;
+ }
+ }
+ }
+ let kr = kr;
+
// ---- report ----
let title_of = |id: &str| -> String {
rs.get(id)
.map(|s| s.title.clone())
- .or_else(|| kr.get(id).map(|l| l.entry.title.clone()))
+ .or_else(|| kr.get(id).and_then(|l| l.entry.as_ref()).map(|e| e.title.clone()))
.unwrap_or_else(|| id.to_string())
};
let mut journal = String::new();
@@ -319,9 +380,11 @@ pub async fn sync_remote<I: Interchange>(
println!(" {verb}: {}", title_of(id));
journal.push_str(&format!("{} sync {verb}: {}\n", now_unix(), title_of(id)));
}
+ let full = |l: &Local<'_>| -> KrEntry { l.entry.clone().expect("read before the plan used it") };
for l in &born {
- println!(" create in 1Password: {}", l.entry.title);
- journal.push_str(&format!("{} sync create in 1Password: {}\n", now_unix(), l.entry.title));
+ let title = &l.entry.as_ref().expect("born items are always read").title;
+ println!(" create in 1Password: {title}");
+ journal.push_str(&format!("{} sync create in 1Password: {title}\n", now_unix()));
*counts.entry("created").or_default() += 1;
}
let c = |k: &str| counts.get(k).copied().unwrap_or(0);
@@ -377,6 +440,14 @@ pub async fn sync_remote<I: Interchange>(
b.op_updated_at = s.updated_raw.clone();
}
}
+ // The keyring's time likewise: an item that was read (its
+ // time moved) yet hashes to the base was touched without a
+ // change. Recording the time lets the next pass skip it.
+ if let (Some(l), Some(b)) = (kr.get(id), next.get_mut(id)) {
+ if l.entry.is_some() && b.keyring_modified != l.modified {
+ b.keyring_modified = l.modified;
+ }
+ }
}
Plan::Forget => {
next.remove(id);
@@ -430,11 +501,11 @@ pub async fn sync_remote<I: Interchange>(
applied += 1;
}
Plan::ToRemote | Plan::ConflictKeyringWins => {
- let l = &kr[id];
- let e = kr_to_remote(&l.entry, id, &l.entry.group);
+ let k = full(&kr[id]);
+ let e = kr_to_remote(&k, id, &k.group);
match remote.update(&e).await {
Ok(updated_raw) => {
- next.insert(id.clone(), snapshot(&l.entry, updated_raw, l.entry.modified));
+ next.insert(id.clone(), snapshot(&k, updated_raw, k.modified));
applied += 1;
}
Err(err) => {
@@ -447,11 +518,12 @@ pub async fn sync_remote<I: Interchange>(
// A keyring entry whose stamp points at nothing any more
// (archived remotely, edited locally): create afresh, restamp.
let l = &kr[id];
- let mut e = kr_to_remote(&l.entry, "", &vault);
+ let entry = full(l);
+ let mut e = kr_to_remote(&entry, "", &vault);
e.vault = vault.clone();
match remote.create(&e).await {
Ok((new_id, updated_raw)) => {
- let mut k = l.entry.clone();
+ let mut k = entry.clone();
k.group = vault.clone();
let attrs = keyring_attrs(&k, &new_id, &l.attrs);
let modified = match async {
@@ -463,7 +535,7 @@ pub async fn sync_remote<I: Interchange>(
Ok(m) => m,
Err(err) => {
eprintln!(" could not restamp {}: {err}", k.title);
- l.entry.modified
+ entry.modified
}
};
next.remove(id);
@@ -483,7 +555,7 @@ pub async fn sync_remote<I: Interchange>(
next.remove(id);
applied += 1;
}
- Err(err) => eprintln!(" keyring delete failed for {}: {err}", l.entry.title),
+ Err(err) => eprintln!(" keyring delete failed for {}: {err}", title_of(id)),
}
}
Plan::RecycleRemote => match remote.recycle(id).await {
@@ -500,7 +572,7 @@ pub async fn sync_remote<I: Interchange>(
}
if failure.is_none() {
for l in &born {
- let mut k = l.entry.clone();
+ let mut k = full(l);
k.group = vault.clone();
let e = kr_to_remote(&k, "", &vault);
match remote.create(&e).await {
@@ -515,7 +587,7 @@ pub async fn sync_remote<I: Interchange>(
Ok(m) => m,
Err(err) => {
eprintln!(" could not stamp {}: {err}", k.title);
- l.entry.modified
+ l.modified
}
};
next.insert(new_id, snapshot(&k, updated_raw, modified));
@@ -610,6 +682,16 @@ mod tests {
assert_eq!(plan(None, Some(&side("R", 10)), Some(&side("K", 0))), Plan::ConflictRemoteWins);
}
+ #[test]
+ fn an_item_is_skipped_only_at_its_base_time() {
+ let base = EntryState { h: "B".into(), keyring_modified: 1_790_000_000, op_updated_at: String::new() };
+ assert!(unchanged_since_base(Some(&base), 1_790_000_000));
+ assert!(!unchanged_since_base(Some(&base), 1_790_000_001), "edited since: read it");
+ assert!(!unchanged_since_base(None, 1_790_000_000), "no base: read it");
+ let zero = EntryState { keyring_modified: 0, ..base };
+ assert!(!unchanged_since_base(Some(&zero), 0), "a zero time proves nothing");
+ }
+
#[test]
fn the_account_item_is_excluded() {
assert!(excluded_title("1Password Account (alice)"));