git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commitfab49c8413470ce5c447bdf9ce26499a6cc82003
parent5a46e6b424
authorLucas Galante <[email protected]>
date2026-09-21 11:50
cce-keyring-sync: Interchange trait, 1Password backend, adopt (phase 1)

The binary becomes a directory module. op.rs holds the Interchange trait
(list/fetch/create/update/recycle) and OnePassword behind it: op as a
direct child under a 75 s timeout, JSON on stdout, item templates on
stdin for writes, never a value on argv; the JSON shapes are the ones
op 2.39.0 prints, pinned by tests. adopt.rs pairs keyring logins with
1Password items on exact (title, username), pins already-stamped ids,
refuses on any duplicate key, reports both sides' leftovers, fetches
each pair to flag field drift from the CSV import (those get an unknown
base timestamp so the first sync reconciles them), then stamps op-item /
op-vault, backs up the kdbx state, and writes a version-2 base keyed by
item id with backend "onepassword". kdbx-* attributes are kept until the
kdbx backend retires. sync/import/doctor refuse under the new backend
until phase 2's daemon lands.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/bin/cce-keyring-sync/adopt.rs                  | 395 +++++++++++++++++++
 .../main.rs}                                       | 120 ++++--
 src/bin/cce-keyring-sync/op.rs                     | 429 +++++++++++++++++++++
 3 files changed, 913 insertions(+), 31 deletions(-)

diff --git a/src/bin/cce-keyring-sync/adopt.rs b/src/bin/cce-keyring-sync/adopt.rs
new file mode 100644
index 0000000..fc2ec2b
--- /dev/null
+++ b/src/bin/cce-keyring-sync/adopt.rs
@@ -0,0 +1,395 @@
+//! `adopt` — the migration step the kdbx never needed.
+//!
+//! After the CSV import, both the keyring and 1Password hold the same
+//! entries with no link between them, so the first run must **pair, not
+//! copy**: match on (title, username), exact and case-sensitive, stamp
+//! `op-item` / `op-vault` on each keyring match, report everything that
+//! did not pair, and write a fresh base snapshot for `sync`. Duplicate keys
+//! on either side make the pairing ambiguous, and a wrong pairing silently
+//! cross-links two accounts — the one mistake the merge cannot undo later —
+//! so any duplicate refuses the whole run until a person has sorted it.
+//!
+//! The kdbx attributes are kept, deliberately: while the kdbx backend still
+//! exists, an accidental kdbx `sync` must keep pairing by uuid rather than
+//! see 171 orphans to re-create. Phase 3 drops them with the backend.
+
+use std::collections::HashMap;
+
+use secret_service::{EncryptionType, SecretService};
+
+use crate::op::{Interchange, OnePassword, RemoteSummary};
+use crate::{keyring_get, now_unix, state_dir, write_state, EntryState, KrEntry, State, APP};
+
+/// Keyring attribute holding the paired 1Password item id.
+pub const OP_ITEM_ATTR: &str = "op-item";
+/// Keyring attribute holding the item's vault name.
+pub const OP_VAULT_ATTR: &str = "op-vault";
+
+/// Pairing key: exact (title, username).
+pub type Key = (String, String);
+
+/// The pairing plan for one side's keys against the other's.
+#[derive(Debug, Default, PartialEq)]
+pub struct PairPlan {
+    /// (local index, remote index)
+    pub pairs: Vec<(usize, usize)>,
+    pub unmatched_local: Vec<usize>,
+    pub unmatched_remote: Vec<usize>,
+    /// Keys that occur more than once on the local (keyring) side.
+    pub dup_local: Vec<Key>,
+    /// Keys that occur more than once on the remote (1Password) side.
+    pub dup_remote: Vec<Key>,
+}
+
+impl PairPlan {
+    /// A duplicate anywhere makes the plan unsafe to apply.
+    pub fn refused(&self) -> bool {
+        !self.dup_local.is_empty() || !self.dup_remote.is_empty()
+    }
+}
+
+/// Pure pairing; `pinned` gives locals already stamped with a remote id
+/// (from an earlier adopt), honoured before any key match so re-running is
+/// idempotent and a retitled entry stays paired.
+pub fn pair(local: &[Key], remote: &[Key], pinned: &[(usize, String)], remote_ids: &[String]) -> PairPlan {
+    let mut plan = PairPlan::default();
+    let mut local_taken = vec![false; local.len()];
+    let mut remote_taken = vec![false; remote.len()];
+
+    let remote_by_id: HashMap<&str, usize> = remote_ids.iter().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
+    for (li, id) in pinned {
+        if let Some(&ri) = remote_by_id.get(id.as_str()) {
+            if !remote_taken[ri] {
+                plan.pairs.push((*li, ri));
+                local_taken[*li] = true;
+                remote_taken[ri] = true;
+            }
+        }
+    }
+
+    let count = |keys: &[Key], taken: &[bool]| -> HashMap<Key, Vec<usize>> {
+        let mut m: HashMap<Key, Vec<usize>> = HashMap::new();
+        for (i, k) in keys.iter().enumerate() {
+            if !taken[i] {
+                m.entry(k.clone()).or_default().push(i);
+            }
+        }
+        m
+    };
+    let lmap = count(local, &local_taken);
+    let rmap = count(remote, &remote_taken);
+
+    let dups = |m: &HashMap<Key, Vec<usize>>| -> Vec<Key> {
+        let mut d: Vec<Key> = m.iter().filter(|(_, v)| v.len() > 1).map(|(k, _)| k.clone()).collect();
+        d.sort();
+        d
+    };
+    plan.dup_local = dups(&lmap);
+    plan.dup_remote = dups(&rmap);
+
+    for (i, k) in local.iter().enumerate() {
+        if local_taken[i] {
+            continue;
+        }
+        match rmap.get(k) {
+            Some(r) if r.len() == 1 && lmap.get(k).map(Vec::len) == Some(1) => {
+                plan.pairs.push((i, r[0]));
+                local_taken[i] = true;
+                remote_taken[r[0]] = true;
+            }
+            _ => plan.unmatched_local.push(i),
+        }
+    }
+    for (i, _) in remote.iter().enumerate() {
+        if !remote_taken[i] {
+            plan.unmatched_remote.push(i);
+        }
+    }
+    plan.pairs.sort();
+    plan
+}
+
+/// One keyring login as adopt sees it.
+struct Local<'a> {
+    item: secret_service::Item<'a>,
+    attrs: HashMap<String, String>,
+    entry: KrEntry,
+}
+
+pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str, dry_run: bool) {
+    let vault = if vault.is_empty() { state.vault.clone() } else { vault.to_string() };
+    let mut remote = OnePassword::new(&vault);
+
+    let ss = match SecretService::connect(EncryptionType::Dh).await {
+        Ok(ss) => ss,
+        Err(e) => {
+            eprintln!("Secret Service unavailable: {e}");
+            std::process::exit(1);
+        }
+    };
+    let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
+        Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
+        _ => {
+            eprintln!("no state hash key — run `cce-keyring-sync import` once (kdbx) first");
+            std::process::exit(1);
+        }
+    };
+    let col = match ss.get_default_collection().await {
+        Ok(c) => c,
+        Err(e) => {
+            eprintln!("no default collection: {e}");
+            std::process::exit(1);
+        }
+    };
+    if col.is_locked().await.unwrap_or(false) && col.unlock().await.is_err() {
+        eprintln!("collection locked");
+        std::process::exit(1);
+    }
+
+    // The keyring's logins: anything cce-secrets or the kdbx import wrote.
+    let mut locals: Vec<Local<'_>> = Vec::new();
+    match col.get_all_items().await {
+        Ok(items) => {
+            for item in items {
+                let Ok(attrs) = item.get_attributes().await else { continue };
+                if attrs.get("application").map(String::as_str) == Some(APP) {
+                    continue;
+                }
+                if !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
+                    continue;
+                }
+                let entry = KrEntry {
+                    title: item.get_label().await.unwrap_or_default(),
+                    username: attrs.get("UserName").cloned().unwrap_or_default(),
+                    password: String::from_utf8_lossy(&item.get_secret().await.unwrap_or_default()).into_owned(),
+                    url: attrs.get("URL").cloned().unwrap_or_default(),
+                    notes: attrs.get("Notes").cloned().unwrap_or_default(),
+                    group: String::new(), // becomes the vault name once paired
+                    modified: item.get_modified().await.unwrap_or(0),
+                };
+                locals.push(Local { item, attrs, entry });
+            }
+        }
+        Err(e) => {
+            eprintln!("listing collection failed: {e}");
+            std::process::exit(1);
+        }
+    }
+    println!("keyring: {} logins", locals.len());
+
+    println!("1Password: listing{} … (an Authorize dialog may appear)", if vault.is_empty() { "" } else { " the vault" });
+    let summaries: Vec<RemoteSummary> = match remote.list().await {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("{e}");
+            std::process::exit(1);
+        }
+    };
+    println!("1Password: {} logins{}", summaries.len(), if vault.is_empty() { String::new() } else { format!(" in {vault}") });
+
+    let lkeys: Vec<Key> = locals.iter().map(|l| (l.entry.title.clone(), l.entry.username.clone())).collect();
+    let rkeys: Vec<Key> = summaries.iter().map(|r| (r.title.clone(), r.username.clone())).collect();
+    let rids: Vec<String> = summaries.iter().map(|r| r.id.clone()).collect();
+    let pinned: Vec<(usize, String)> = locals
+        .iter()
+        .enumerate()
+        .filter_map(|(i, l)| l.attrs.get(OP_ITEM_ATTR).map(|id| (i, id.clone())))
+        .collect();
+    let plan = pair(&lkeys, &rkeys, &pinned, &rids);
+
+    let show = |k: &Key| if k.1.is_empty() { k.0.clone() } else { format!("{}  ({})", k.0, k.1) };
+    if !plan.dup_local.is_empty() {
+        println!("\nDUPLICATE (title, username) in the keyring — cannot pair safely:");
+        for k in &plan.dup_local {
+            println!("  {}", show(k));
+        }
+    }
+    if !plan.dup_remote.is_empty() {
+        println!("\nDUPLICATE (title, username) in 1Password — archive the stale copy, then retry:");
+        for k in &plan.dup_remote {
+            println!("  {}", show(k));
+        }
+    }
+    if !plan.unmatched_remote.is_empty() {
+        println!("\nin 1Password only ({}): left alone now; sync would mirror them into the keyring", plan.unmatched_remote.len());
+        for &i in &plan.unmatched_remote {
+            println!("  {}", show(&rkeys[i]));
+        }
+    }
+    if !plan.unmatched_local.is_empty() {
+        println!("\nin the keyring only ({}): left alone now; sync would create them in 1Password", plan.unmatched_local.len());
+        for &i in &plan.unmatched_local {
+            println!("  {}", show(&lkeys[i]));
+        }
+    }
+    println!(
+        "\npairs: {} of {} keyring / {} 1Password ({} already stamped)",
+        plan.pairs.len(),
+        locals.len(),
+        summaries.len(),
+        pinned.len()
+    );
+    if plan.refused() {
+        eprintln!("refusing: duplicates make the pairing ambiguous; nothing changed");
+        std::process::exit(1);
+    }
+
+    // Field check: the CSV import may have normalised urls or notes. Those
+    // entries get an unknown base timestamp so the first sync fetches and
+    // reconciles them, taking 1Password's value.
+    println!("fetching {} paired items to compare fields …", plan.pairs.len());
+    let mut differing: Vec<usize> = Vec::new();
+    let mut fetched: HashMap<usize, crate::op::RemoteEntry> = HashMap::new();
+    for (n, &(li, ri)) in plan.pairs.iter().enumerate() {
+        if n > 0 && n % 25 == 0 {
+            println!("  {n}/{}", plan.pairs.len());
+        }
+        match remote.fetch(&rids[ri]).await {
+            Ok(e) => {
+                let l = &locals[li].entry;
+                if e.password != l.password || e.url != l.url || e.notes != l.notes {
+                    differing.push(li);
+                }
+                fetched.insert(ri, e);
+            }
+            Err(err) => {
+                eprintln!("{err}");
+                std::process::exit(1);
+            }
+        }
+    }
+    if !differing.is_empty() {
+        println!("\nfields differ on {} paired entries (password/url/notes); the first sync takes 1Password's value:", differing.len());
+        for &li in &differing {
+            let l = &locals[li].entry;
+            let r = &fetched[&plan.pairs.iter().find(|(a, _)| *a == li).unwrap().1];
+            let mut what = Vec::new();
+            if r.password != l.password {
+                what.push("password");
+            }
+            if r.url != l.url {
+                what.push("url");
+            }
+            if r.notes != l.notes {
+                what.push("notes");
+            }
+            println!("  {}  [{}]", show(&lkeys[li]), what.join(", "));
+        }
+    }
+
+    if dry_run {
+        println!("\ndry run — nothing changed");
+        return;
+    }
+
+    // ---- apply: stamp, then state ----
+    let mut stamped = 0usize;
+    let mut entries: HashMap<String, EntryState> = HashMap::new();
+    for &(li, ri) in &plan.pairs {
+        let l = &locals[li];
+        let r = &fetched[&ri];
+        let mut attrs: HashMap<&str, &str> = l.attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
+        attrs.insert(OP_ITEM_ATTR, r.id.as_str());
+        attrs.insert(OP_VAULT_ATTR, r.vault.as_str());
+        if let Err(e) = l.item.set_attributes(attrs).await {
+            eprintln!("  could not stamp {}: {e}", l.entry.title);
+            continue;
+        }
+        stamped += 1;
+        let mut base = l.entry.clone();
+        base.group = r.vault.clone();
+        entries.insert(
+            r.id.clone(),
+            EntryState {
+                h: base.hash(&hash_key),
+                kdbx_mtime: 0,
+                keyring_modified: l.entry.modified,
+                op_updated_at: if differing.contains(&li) { String::new() } else { r.updated_raw.clone() },
+            },
+        );
+    }
+
+    // The kdbx base is not thrown away: sync under the old backend could
+    // still be wanted if this migration is rolled back.
+    if state.backend != "onepassword" && state_path.exists() {
+        let backup = state_dir().join(format!("state.json.kdbx-{}", now_unix()));
+        if std::fs::copy(state_path, &backup).is_ok() {
+            println!("kdbx sync state backed up to {}", backup.display());
+        }
+    }
+    state.version = 2;
+    state.backend = "onepassword".to_string();
+    state.vault = vault.clone();
+    state.entries = entries;
+    state.last_run = now_unix();
+    write_state(state_path, &state);
+    crate::journal_append(&format!("{} adopt stamped {stamped} entries (1Password, vault {vault})\n", now_unix()));
+    println!("\nadopted: {stamped} entries stamped; backend is now 1Password");
+    println!("note: the kdbx timer should be stopped — `sync` refuses under this backend until the daemon lands (phase 2)");
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn k(t: &str, u: &str) -> Key {
+        (t.to_string(), u.to_string())
+    }
+
+    #[test]
+    fn exact_keys_pair_and_the_rest_are_reported() {
+        let local = vec![k("GitHub", "me"), k("Bank", "me"), k("Old", "x")];
+        let remote = vec![k("Bank", "me"), k("GitHub", "me"), k("New", "y")];
+        let ids = vec!["r0".into(), "r1".into(), "r2".into()];
+        let p = pair(&local, &remote, &[], &ids);
+        assert_eq!(p.pairs, vec![(0, 1), (1, 0)]);
+        assert_eq!(p.unmatched_local, vec![2]);
+        assert_eq!(p.unmatched_remote, vec![2]);
+        assert!(!p.refused());
+    }
+
+    #[test]
+    fn matching_is_case_sensitive_and_username_aware() {
+        let local = vec![k("GitHub", "me"), k("Mail", "a")];
+        let remote = vec![k("github", "me"), k("Mail", "b")];
+        let ids = vec!["r0".into(), "r1".into()];
+        let p = pair(&local, &remote, &[], &ids);
+        assert!(p.pairs.is_empty());
+        assert_eq!(p.unmatched_local, vec![0, 1]);
+        assert_eq!(p.unmatched_remote, vec![0, 1]);
+    }
+
+    #[test]
+    fn a_duplicate_on_either_side_refuses_that_key_and_the_run() {
+        let local = vec![k("Bank", "me"), k("Bank", "me"), k("Mail", "a")];
+        let remote = vec![k("Bank", "me"), k("Mail", "a"), k("Mail", "a")];
+        let ids = vec!["r0".into(), "r1".into(), "r2".into()];
+        let p = pair(&local, &remote, &[], &ids);
+        assert!(p.refused());
+        assert_eq!(p.dup_local, vec![k("Bank", "me")]);
+        assert_eq!(p.dup_remote, vec![k("Mail", "a")]);
+        assert!(p.pairs.is_empty(), "an ambiguous key never pairs, even its single-sided partner");
+        assert_eq!(p.unmatched_local, vec![0, 1, 2]);
+    }
+
+    #[test]
+    fn a_pinned_id_wins_over_the_key_and_survives_a_retitle() {
+        let local = vec![k("Bank (renamed)", "me"), k("Bank", "me")];
+        let remote = vec![k("Bank", "me")];
+        let ids = vec!["r0".into()];
+        // local 0 was stamped r0 in an earlier run and then retitled keyring-side.
+        let p = pair(&local, &remote, &[(0, "r0".into())], &ids);
+        assert_eq!(p.pairs, vec![(0, 0)]);
+        assert_eq!(p.unmatched_local, vec![1], "the key match loses to the pin");
+        assert!(p.unmatched_remote.is_empty());
+    }
+
+    #[test]
+    fn a_pin_to_a_vanished_id_falls_back_to_the_key() {
+        let local = vec![k("Bank", "me")];
+        let remote = vec![k("Bank", "me")];
+        let ids = vec!["r-new".into()];
+        let p = pair(&local, &remote, &[(0, "r-old".into())], &ids);
+        assert_eq!(p.pairs, vec![(0, 0)]);
+    }
+}
diff --git a/src/bin/cce-keyring-sync.rs b/src/bin/cce-keyring-sync/main.rs
similarity index 92%
rename from src/bin/cce-keyring-sync.rs
rename to src/bin/cce-keyring-sync/main.rs
index c3a264f..6af163e 100644
--- a/src/bin/cce-keyring-sync.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -16,6 +16,9 @@
 //! - attribute names match cce-secrets and KeePassXC's own Secret Service
 //!   bridge: label=Title, UserName, URL, Notes, plus kdbx-uuid / kdbx-group.
 
+mod adopt;
+mod op;
+
 use std::collections::HashMap;
 use std::io::Cursor;
 use std::path::{Path, PathBuf};
@@ -25,7 +28,7 @@ use secret_service::{EncryptionType, SecretService};
 use serde::{Deserialize, Serialize};
 
 const DEFAULT_KDBX: &str = "Dropbox/Codes/Passwords.kdbx";
-const APP: &str = "cce-keyring-sync";
+pub(crate) const APP: &str = "cce-keyring-sync";
 
 /// One kdbx entry, flattened to what round-trips (KEYRING-SYNC.md: TOTP,
 /// attachments and history deliberately stay kdbx-side).
@@ -41,23 +44,36 @@ struct KdbxEntry {
 }
 
 #[derive(Serialize, Deserialize, Default)]
-struct State {
-    version: u32,
-    kdbx_path: String,
-    last_run: i64,
-    /// Per kdbx UUID: the last-synced snapshot phase 2 merges against.
-    entries: HashMap<String, EntryState>,
+pub(crate) struct State {
+    pub version: u32,
+    pub kdbx_path: String,
+    pub last_run: i64,
+    /// Which interchange the base snapshot belongs to: "" (kdbx, the
+    /// original) or "onepassword" (set by `adopt`). The two key `entries`
+    /// differently — kdbx UUID vs 1Password item id — so a snapshot is only
+    /// ever meaningful to its own backend.
+    #[serde(default)]
+    pub backend: String,
+    /// 1Password only: the vault new entries are created in.
+    #[serde(default)]
+    pub vault: String,
+    /// Per entry id: the last-synced snapshot the merge runs against.
+    pub entries: HashMap<String, EntryState>,
 }
 
 #[derive(Serialize, Deserialize)]
-struct EntryState {
+pub(crate) struct EntryState {
     /// Keyed blake3 over the canonical field concatenation — never values.
-    h: String,
-    kdbx_mtime: i64,
-    keyring_modified: u64,
+    pub h: String,
+    pub kdbx_mtime: i64,
+    pub keyring_modified: u64,
+    /// 1Password's `updated_at` at the base, verbatim. Empty means unknown:
+    /// the next sync fetches the entry regardless of the list timestamp.
+    #[serde(default)]
+    pub op_updated_at: String,
 }
 
-fn state_dir() -> PathBuf {
+pub(crate) fn state_dir() -> PathBuf {
     let base = std::env::var("XDG_STATE_HOME")
         .ok()
         .filter(|s| !s.is_empty())
@@ -70,7 +86,7 @@ fn home() -> PathBuf {
     PathBuf::from(std::env::var("HOME").expect("HOME"))
 }
 
-fn now_unix() -> i64 {
+pub(crate) fn now_unix() -> i64 {
     std::time::SystemTime::now()
         .duration_since(std::time::UNIX_EPOCH)
         .map(|d| d.as_secs() as i64)
@@ -169,7 +185,7 @@ fn attrs_for(e: &KdbxEntry) -> HashMap<&str, &str> {
 /// A secret held as a keyring item under our own application attribute:
 /// the kdbx master password and the state-file hash key both live this way,
 /// unlocked by PAM along with everything else.
-async fn keyring_get(
+pub(crate) async fn keyring_get(
     ss: &SecretService<'_>,
     purpose: &str,
 ) -> Result<Option<Vec<u8>>, secret_service::Error> {
@@ -212,19 +228,39 @@ async fn main() {
         .position(|a| a == "--kdbx")
         .and_then(|i| args.get(i + 1))
         .cloned();
-    let cmd = args
+    let vault_flag = args
         .iter()
-        .find(|a| !a.starts_with("--") && Some(a.as_str()) != kdbx_flag.as_deref().map(|_| "").or(None))
+        .position(|a| a == "--vault")
+        .and_then(|i| args.get(i + 1))
         .cloned();
+    // The subcommand: the first word that is neither a flag nor a flag's value.
+    let mut skip_next = false;
+    let mut cmd = None;
+    for a in &args {
+        if skip_next {
+            skip_next = false;
+            continue;
+        }
+        if a == "--kdbx" || a == "--vault" {
+            skip_next = true;
+            continue;
+        }
+        if !a.starts_with("--") {
+            cmd = Some(a.clone());
+            break;
+        }
+    }
     let cmd = match cmd.as_deref() {
         Some("import") => "import",
         Some("sync") => "sync",
         Some("doctor") => "doctor",
         Some("status") => "status",
+        Some("adopt") => "adopt",
         _ => {
             eprintln!("usage: cce-keyring-sync sync   [--dry-run] [--kdbx <path>]");
             eprintln!("       cce-keyring-sync import [--dry-run] [--kdbx <path>]");
             eprintln!("       cce-keyring-sync doctor [--kdbx <path>]");
+            eprintln!("       cce-keyring-sync adopt  [--dry-run] [--vault <name>]   (pair the keyring with 1Password)");
             eprintln!("       cce-keyring-sync status");
             std::process::exit(2);
         }
@@ -241,14 +277,33 @@ async fn main() {
         .or_else(|| (!state.kdbx_path.is_empty()).then(|| PathBuf::from(&state.kdbx_path)))
         .unwrap_or_else(|| home().join(DEFAULT_KDBX));
 
+    let onepassword = state.backend == "onepassword";
     if cmd == "status" {
-        println!("kdbx:      {}", kdbx.display());
+        if onepassword {
+            println!("backend:   1Password (vault {})", if state.vault.is_empty() { "*" } else { &state.vault });
+        } else {
+            println!("backend:   kdbx");
+            println!("kdbx:      {}", kdbx.display());
+        }
         println!("state:     {} entries, last run {}", state.entries.len(), state.last_run);
-        for c in conflicted_copies(&kdbx) {
-            println!("CONFLICT:  {}", c.display());
+        if !onepassword {
+            for c in conflicted_copies(&kdbx) {
+                println!("CONFLICT:  {}", c.display());
+            }
         }
         return;
     }
+    if cmd == "adopt" {
+        adopt::adopt(&state_path, state, vault_flag.as_deref().unwrap_or(""), dry_run).await;
+        return;
+    }
+    if onepassword {
+        // The kdbx paths key their base by kdbx UUID; running one against a
+        // 1Password base would re-plan every entry from nothing.
+        eprintln!("the sync base belongs to the 1Password backend; `{cmd}` is kdbx-only");
+        eprintln!("(phase 2's daemon is not built yet — see KEYRING-SYNC.md)");
+        std::process::exit(1);
+    }
 
     if cmd == "doctor" {
         doctor(&kdbx).await;
@@ -437,7 +492,7 @@ async fn main() {
         };
         state.entries.insert(
             e.uuid.clone(),
-            EntryState { h: canonical_hash(&hash_key, e), kdbx_mtime: e.mtime, keyring_modified: modified },
+            EntryState { h: canonical_hash(&hash_key, e), kdbx_mtime: e.mtime, keyring_modified: modified, op_updated_at: String::new() },
         );
     }
 
@@ -469,18 +524,20 @@ async fn main() {
 // ===================== phase 2: bidirectional sync =====================
 
 /// A keyring item's synced fields, snapshotted once per run.
-struct KrEntry {
-    title: String,
-    username: String,
-    password: String,
-    url: String,
-    notes: String,
-    group: String,
-    modified: u64,
+#[derive(Clone)]
+pub(crate) struct KrEntry {
+    pub title: String,
+    pub username: String,
+    pub password: String,
+    pub url: String,
+    pub notes: String,
+    /// kdbx: the group name; 1Password: the vault name.
+    pub group: String,
+    pub modified: u64,
 }
 
 impl KrEntry {
-    fn hash(&self, key: &[u8; 32]) -> String {
+    pub fn hash(&self, key: &[u8; 32]) -> String {
         let mut h = blake3::Hasher::new_keyed(key);
         for part in [&self.title, &self.username, &self.password, &self.url, &self.notes, &self.group] {
             h.update(part.as_bytes());
@@ -522,7 +579,7 @@ fn take_lock() -> Option<std::fs::File> {
     }
 }
 
-fn journal_append(lines: &str) {
+pub(crate) fn journal_append(lines: &str) {
     use std::io::Write;
     if let Ok(mut f) = std::fs::OpenOptions::new()
         .create(true)
@@ -1010,6 +1067,7 @@ async fn sync(kdbx_path: &Path, state_path: &Path, mut state: State, dry_run: bo
                 h: canonical_hash(&hash_key, e),
                 kdbx_mtime: e.mtime,
                 keyring_modified: fresh_kr.get(&e.uuid).copied().unwrap_or(0),
+                op_updated_at: String::new(),
             },
         );
     }
@@ -1025,7 +1083,7 @@ async fn sync(kdbx_path: &Path, state_path: &Path, mut state: State, dry_run: bo
     );
 }
 
-fn write_state(state_path: &Path, state: &State) {
+pub(crate) fn write_state(state_path: &Path, state: &State) {
     let _ = std::fs::create_dir_all(state_dir());
     let tmp = state_path.with_extension("json.tmp");
     if std::fs::write(&tmp, serde_json::to_vec_pretty(state).unwrap()).is_ok() {
diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
new file mode 100644
index 0000000..f9b23e8
--- /dev/null
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -0,0 +1,429 @@
+//! The interchange seam, and 1Password behind it.
+//!
+//! KEYRING-SYNC.md ("Scoping: 1Password as the interchange") is the design.
+//! Everything 1Password-specific is a child `op` process with JSON on stdout
+//! and, for writes, a JSON item template on stdin — **never a value on
+//! argv**, which every same-user process can read. Each spawn runs under
+//! [`OP_TIMEOUT`]: an unanswered Authorize dialog holds `op` for 60 s before
+//! it gives up, and a wedged app must not hold a tick forever.
+//!
+//! The `Interchange` trait is the shape the merge loop will call in phase 2;
+//! the kdbx backend joins it when `sync` is rewired, not before — an
+//! unexercised impl is dead code.
+
+use std::time::Duration;
+
+use serde_json::{json, Value};
+
+/// Longer than the app's own 60-second dialog timeout, so a dismissed
+/// prompt reports itself as such instead of as a kill.
+pub const OP_TIMEOUT: Duration = Duration::from_secs(75);
+
+/// The text `op` prints when the Authorize dialog timed out unanswered.
+const DISMISSED: &str = "authorization prompt dismissed";
+
+/// One remote entry, whole: the six fields the merge hashes plus identity.
+#[derive(Clone, Debug, PartialEq, Default)]
+pub struct RemoteEntry {
+    pub id: String,
+    /// 1Password: the vault name (stored keyring-side as `op-vault`).
+    pub vault: String,
+    pub title: String,
+    pub username: String,
+    pub password: String,
+    pub url: String,
+    pub notes: String,
+    /// Server-side modification time, unix seconds (0 when unparseable).
+    pub updated: i64,
+    /// The interchange's own timestamp text, verbatim, so a base snapshot
+    /// compares without re-parsing (1Password: RFC 3339 `updated_at`).
+    pub updated_raw: String,
+}
+
+/// What `list` returns: everything but the secret fields, so a quiet run
+/// never touches a password.
+#[derive(Clone, Debug, PartialEq, Default)]
+pub struct RemoteSummary {
+    pub id: String,
+    pub vault: String,
+    pub title: String,
+    pub username: String,
+    pub url: String,
+    pub updated: i64,
+    pub updated_raw: String,
+}
+
+/// The cross-machine store the keyring is mirrored against.
+#[allow(dead_code)] // create/update/recycle are phase 2's callers
+pub trait Interchange {
+    fn name(&self) -> &'static str;
+    /// Every login the store holds — no secrets.
+    async fn list(&mut self) -> Result<Vec<RemoteSummary>, String>;
+    /// One entry in full.
+    async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>;
+    /// Store a new entry; returns its id. `e.id` is ignored.
+    async fn create(&mut self, e: &RemoteEntry) -> Result<String, String>;
+    /// Overwrite an existing entry's synced fields, leaving the rest alone.
+    async fn update(&mut self, e: &RemoteEntry) -> Result<(), String>;
+    /// Soft-delete: 1Password's Archive, the kdbx's Recycle Bin.
+    async fn recycle(&mut self, id: &str) -> Result<(), String>;
+}
+
+/// True when the error text is the app's dialog timing out — a refusal to
+/// back off from, not a fault to log as one. Phase 2's daemon is the caller.
+#[allow(dead_code)]
+pub fn is_dismissed(err: &str) -> bool {
+    err.contains(DISMISSED)
+}
+
+// ───────────────────────────── 1Password ─────────────────────────────
+
+pub struct OnePassword {
+    /// Vault to list from and create into. Empty means every vault `op`
+    /// can read; creates then need a name, so `create` refuses.
+    pub vault: String,
+    /// `--account`, for a person with several signed in. Empty: op's default.
+    pub account: String,
+}
+
+impl OnePassword {
+    pub fn new(vault: &str) -> Self {
+        OnePassword { vault: vault.to_string(), account: String::new() }
+    }
+
+    /// Spawn `op` as a direct child (the authorization is keyed to *our*
+    /// pid as its parent — never via a shell, setsid, or a double fork),
+    /// feed `stdin`, and return stdout. Stderr's last line is the error.
+    async fn run(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
+        use tokio::io::AsyncWriteExt;
+        let mut cmd = tokio::process::Command::new("op");
+        cmd.args(args).arg("--format").arg("json").arg("--no-color");
+        if !self.account.is_empty() {
+            cmd.arg("--account").arg(&self.account);
+        }
+        cmd.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .kill_on_drop(true);
+        let mut child = cmd.spawn().map_err(|e| format!("op not runnable: {e}"))?;
+        if let Some(bytes) = stdin {
+            let mut pipe = child.stdin.take().expect("piped stdin");
+            // A closed pipe (op exited early) is reported by wait, not here.
+            let _ = pipe.write_all(&bytes).await;
+            drop(pipe);
+        }
+        let out = match tokio::time::timeout(OP_TIMEOUT, child.wait_with_output()).await {
+            Ok(Ok(out)) => out,
+            Ok(Err(e)) => return Err(format!("op failed to run: {e}")),
+            Err(_) => return Err(format!("op timed out after {}s (app wedged?)", OP_TIMEOUT.as_secs())),
+        };
+        if out.status.success() {
+            return Ok(out.stdout);
+        }
+        let err = String::from_utf8_lossy(&out.stderr);
+        let last = err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
+        // op prefixes "[ERROR] 2026/09/21 10:15:39 "; keep what follows.
+        let msg = last.splitn(4, ' ').nth(3).unwrap_or(last);
+        Err(format!("op {}: {msg}", args.first().copied().unwrap_or("")))
+    }
+
+    async fn run_json(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Value, String> {
+        let bytes = self.run(args, stdin).await?;
+        serde_json::from_slice(&bytes).map_err(|e| format!("op {}: unparseable JSON: {e}", args.join(" ")))
+    }
+}
+
+impl Interchange for OnePassword {
+    fn name(&self) -> &'static str {
+        "onepassword"
+    }
+
+    async fn list(&mut self) -> Result<Vec<RemoteSummary>, String> {
+        let mut args = vec!["item", "list", "--categories", "Login"];
+        if !self.vault.is_empty() {
+            args.extend(["--vault", self.vault.as_str()]);
+        }
+        let v = self.run_json(&args, None).await?;
+        let items = v.as_array().ok_or("op item list: not an array")?;
+        Ok(items.iter().map(summary_from_json).collect())
+    }
+
+    async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String> {
+        let v = self.run_json(&["item", "get", id], None).await?;
+        Ok(entry_from_json(&v))
+    }
+
+    async fn create(&mut self, e: &RemoteEntry) -> Result<String, String> {
+        if self.vault.is_empty() {
+            return Err("no vault configured for new entries (adopt --vault <name>)".into());
+        }
+        let template = serde_json::to_vec(&create_template(e)).unwrap();
+        let v = self
+            .run_json(&["item", "create", "--vault", self.vault.as_str(), "-"], Some(template))
+            .await?;
+        v.get("id")
+            .and_then(Value::as_str)
+            .map(str::to_string)
+            .ok_or_else(|| "op item create: no id in reply".to_string())
+    }
+
+    async fn update(&mut self, e: &RemoteEntry) -> Result<(), String> {
+        // Round-trip the whole item so sections, custom fields and tags
+        // survive; only the synced fields are rewritten.
+        let mut v = self.run_json(&["item", "get", &e.id], None).await?;
+        apply_entry(&mut v, e);
+        let body = serde_json::to_vec(&v).unwrap();
+        self.run_json(&["item", "edit", &e.id], Some(body)).await.map(|_| ())
+    }
+
+    async fn recycle(&mut self, id: &str) -> Result<(), String> {
+        // `delete --archive` prints nothing; run, not run_json.
+        self.run(&["item", "delete", id, "--archive"], None).await.map(|_| ())
+    }
+}
+
+// ───────────────────────────── JSON shapes ─────────────────────────────
+//
+// Captured from op 2.39.0 (2026-09-21). `item list` gives id, title,
+// vault{id,name}, category, urls[{href,primary}], additional_information
+// (the username for logins), created_at, updated_at. `item get` adds
+// fields[{id,type,purpose,label,value,…}] with purpose USERNAME / PASSWORD /
+// NOTES; a NOTES field with no value has no `value` key at all.
+
+fn s(v: &Value, key: &str) -> String {
+    v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
+}
+
+fn primary_url(v: &Value) -> String {
+    let Some(urls) = v.get("urls").and_then(Value::as_array) else { return String::new() };
+    urls.iter()
+        .find(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
+        .or_else(|| urls.first())
+        .map(|u| s(u, "href"))
+        .unwrap_or_default()
+}
+
+fn field_by_purpose<'a>(v: &'a Value, purpose: &str) -> Option<&'a Value> {
+    v.get("fields")?
+        .as_array()?
+        .iter()
+        .find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose))
+}
+
+pub fn summary_from_json(v: &Value) -> RemoteSummary {
+    let updated_raw = s(v, "updated_at");
+    RemoteSummary {
+        id: s(v, "id"),
+        vault: v.get("vault").map(|x| s(x, "name")).unwrap_or_default(),
+        title: s(v, "title"),
+        username: s(v, "additional_information"),
+        url: primary_url(v),
+        updated: parse_rfc3339(&updated_raw).unwrap_or(0),
+        updated_raw,
+    }
+}
+
+pub fn entry_from_json(v: &Value) -> RemoteEntry {
+    let sum = summary_from_json(v);
+    let field = |p: &str| field_by_purpose(v, p).map(|f| s(f, "value")).unwrap_or_default();
+    RemoteEntry {
+        id: sum.id,
+        vault: sum.vault,
+        title: sum.title,
+        // The field is authoritative; additional_information is its echo.
+        username: field("USERNAME"),
+        password: field("PASSWORD"),
+        url: sum.url,
+        notes: field("NOTES"),
+        updated: sum.updated,
+        updated_raw: sum.updated_raw,
+    }
+}
+
+/// The Login template `op item template get Login` prints, filled in.
+pub fn create_template(e: &RemoteEntry) -> Value {
+    let mut t = json!({
+        "title": e.title,
+        "category": "LOGIN",
+        "fields": [
+            {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": e.username},
+            {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": e.password},
+            {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain", "value": e.notes},
+        ]
+    });
+    if !e.url.is_empty() {
+        t["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
+    }
+    t
+}
+
+/// Rewrite the synced fields of a fetched item in place.
+pub fn apply_entry(v: &mut Value, e: &RemoteEntry) {
+    v["title"] = json!(e.title);
+    // The primary URL is replaced (or added); other URLs are left alone.
+    let urls = v.get_mut("urls").and_then(Value::as_array_mut);
+    match urls {
+        Some(list) if !list.is_empty() => {
+            let idx = list
+                .iter()
+                .position(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
+                .unwrap_or(0);
+            if e.url.is_empty() {
+                list.remove(idx);
+            } else {
+                list[idx]["href"] = json!(e.url);
+            }
+        }
+        _ => {
+            if !e.url.is_empty() {
+                v["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
+            }
+        }
+    }
+    let set = |v: &mut Value, purpose: &str, id: &str, kind: &str, value: &str| {
+        let fields = v
+            .as_object_mut()
+            .expect("item object")
+            .entry("fields")
+            .or_insert_with(|| json!([]));
+        let list = fields.as_array_mut().expect("fields array");
+        match list.iter_mut().find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose)) {
+            Some(f) => f["value"] = json!(value),
+            None => list.push(json!({"id": id, "type": kind, "purpose": purpose, "label": id, "value": value})),
+        }
+    };
+    set(v, "USERNAME", "username", "STRING", &e.username);
+    set(v, "PASSWORD", "password", "CONCEALED", &e.password);
+    set(v, "NOTES", "notesPlain", "STRING", &e.notes);
+}
+
+/// `2026-09-21T15:41:14Z` (optionally with fraction) → unix seconds. Only
+/// the UTC form 1Password emits; anything else is None, and the caller
+/// treats 0 as "unknown", which the skew tolerance already absorbs.
+pub fn parse_rfc3339(t: &str) -> Option<i64> {
+    let t = t.strip_suffix('Z')?;
+    let (date, time) = t.split_once('T')?;
+    let mut d = date.split('-').map(|p| p.parse::<i64>().ok());
+    let (y, m, day) = (d.next()??, d.next()??, d.next()??);
+    let time = time.split('.').next()?;
+    let mut c = time.split(':').map(|p| p.parse::<i64>().ok());
+    let (h, mi, sec) = (c.next()??, c.next()??, c.next()??);
+    // Howard Hinnant's days-from-civil.
+    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
+    let era = y.div_euclid(400);
+    let yoe = y - era * 400;
+    let doy = (153 * m + 2) / 5 + day - 1;
+    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+    let days = era * 146097 + doe - 719468;
+    Some(days * 86400 + h * 3600 + mi * 60 + sec)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const LIST_ITEM: &str = r#"{
+        "id": "abc", "title": "Example", "tags": [], "version": 1,
+        "vault": {"id": "v1", "name": "Personal"}, "category": "LOGIN",
+        "created_at": "2026-09-21T15:41:14Z", "updated_at": "2026-09-21T15:41:14Z",
+        "additional_information": "someone",
+        "urls": [{"href": "https://old.example.com"}, {"primary": true, "href": "https://example.com"}]
+    }"#;
+
+    #[test]
+    fn a_list_item_yields_a_summary_with_no_secret() {
+        let v: Value = serde_json::from_str(LIST_ITEM).unwrap();
+        let s = summary_from_json(&v);
+        assert_eq!(s.id, "abc");
+        assert_eq!(s.vault, "Personal");
+        assert_eq!(s.username, "someone");
+        assert_eq!(s.url, "https://example.com", "the primary url wins over the first");
+        assert_eq!(s.updated, 1790005274);
+        assert_eq!(s.updated_raw, "2026-09-21T15:41:14Z");
+    }
+
+    #[test]
+    fn a_full_item_reads_its_fields_by_purpose() {
+        let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
+        v["fields"] = json!([
+            {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
+            {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "hunter2"},
+            {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain"}
+        ]);
+        let e = entry_from_json(&v);
+        assert_eq!(e.password, "hunter2");
+        assert_eq!(e.notes, "", "a notes field without a value is empty, not missing");
+        assert_eq!(e.username, "someone");
+    }
+
+    #[test]
+    fn a_missing_url_list_is_empty() {
+        let v: Value = json!({"id": "x", "title": "t", "updated_at": "nope"});
+        let s = summary_from_json(&v);
+        assert_eq!(s.url, "");
+        assert_eq!(s.updated, 0);
+    }
+
+    #[test]
+    fn the_create_template_matches_op_s_login_shape() {
+        let e = RemoteEntry {
+            title: "T".into(),
+            username: "u".into(),
+            password: "p".into(),
+            url: "https://x.example".into(),
+            notes: "n".into(),
+            ..Default::default()
+        };
+        let t = create_template(&e);
+        assert_eq!(t["category"], "LOGIN");
+        assert_eq!(t["urls"][0]["primary"], true);
+        assert_eq!(t["urls"][0]["href"], "https://x.example");
+        let e2 = entry_from_json(&t);
+        assert_eq!((e2.title, e2.username, e2.password, e2.url, e2.notes), ("T".into(), "u".into(), "p".into(), "https://x.example".into(), "n".into()));
+        let no_url = create_template(&RemoteEntry::default());
+        assert!(no_url.get("urls").is_none(), "an empty url adds no urls key");
+    }
+
+    #[test]
+    fn apply_entry_rewrites_synced_fields_and_keeps_the_rest() {
+        let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
+        v["fields"] = json!([
+            {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
+            {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "old"},
+            {"id": "custom", "type": "STRING", "label": "pin", "value": "1234", "section": {"id": "s1"}}
+        ]);
+        let e = RemoteEntry {
+            id: "abc".into(),
+            title: "Renamed".into(),
+            username: "someone".into(),
+            password: "new".into(),
+            url: "https://new.example.com".into(),
+            notes: "added".into(),
+            ..Default::default()
+        };
+        apply_entry(&mut v, &e);
+        assert_eq!(v["title"], "Renamed");
+        assert_eq!(v["urls"][1]["href"], "https://new.example.com", "the primary url is replaced in place");
+        assert_eq!(v["urls"][0]["href"], "https://old.example.com", "other urls survive");
+        let got = entry_from_json(&v);
+        assert_eq!(got.password, "new");
+        assert_eq!(got.notes, "added", "a missing purpose field is appended");
+        assert_eq!(v["fields"][2]["value"], "1234", "custom fields survive");
+        assert_eq!(v["tags"], json!([]), "unrelated keys survive");
+    }
+
+    #[test]
+    fn rfc3339_parses_1password_timestamps_only() {
+        assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
+        assert_eq!(parse_rfc3339("2026-09-21T15:41:14Z"), Some(1790005274));
+        assert_eq!(parse_rfc3339("2026-09-21T15:41:14.5Z"), Some(1790005274));
+        assert_eq!(parse_rfc3339("2026-09-21T15:41:14+02:00"), None);
+        assert_eq!(parse_rfc3339(""), None);
+    }
+
+    #[test]
+    fn a_dismissed_prompt_is_recognised() {
+        assert!(is_dismissed("op item list: authorization prompt dismissed, please try again"));
+        assert!(!is_dismissed("op item list: account is not signed in"));
+    }
+}