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

src/bin/cce-keyring-sync/adopt.rs (19.5K)

  1 //! `adopt` — pair what both sides already hold, and seed the base.
  2 //!
  3 //! After the CSV import, both the keyring and 1Password hold the same
  4 //! entries with no link between them, so the first run must **pair, not
  5 //! copy**: match on (title, username), exact and case-sensitive, stamp
  6 //! `op-item` / `op-vault` on each keyring match, report everything that
  7 //! did not pair, and write a fresh base snapshot for `sync`. Duplicate keys
  8 //! on either side make the pairing ambiguous, and a wrong pairing silently
  9 //! cross-links two accounts — the one mistake the merge cannot undo later —
 10 //! so any duplicate refuses the whole run until a person has sorted it.
 11 //!
 12 //! Old `kdbx-*` attributes are left on the items; nothing reads them.
 13 
 14 use std::collections::HashMap;
 15 
 16 use secret_service::{EncryptionType, SecretService};
 17 
 18 use crate::op::{Interchange, OnePassword, RemoteSummary};
 19 use crate::{keyring_get, now_unix, state_dir, write_state, EntryState, KrEntry, State, APP};
 20 
 21 /// Keyring attribute holding the paired 1Password item id.
 22 pub const OP_ITEM_ATTR: &str = "op-item";
 23 /// Keyring attribute holding the item's vault name.
 24 pub const OP_VAULT_ATTR: &str = "op-vault";
 25 
 26 /// Pairing key: exact (title, username), with the url as the tiebreaker
 27 /// when the pair alone is ambiguous (KEYRING-SYNC.md open question 2).
 28 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
 29 pub struct Key {
 30     pub title: String,
 31     pub username: String,
 32     pub url: String,
 33 }
 34 
 35 impl Key {
 36     fn short(&self) -> (String, String) {
 37         (self.title.clone(), self.username.clone())
 38     }
 39 }
 40 
 41 /// The pairing plan for one side's keys against the other's.
 42 #[derive(Debug, Default, PartialEq)]
 43 pub struct PairPlan {
 44     /// (local index, remote index)
 45     pub pairs: Vec<(usize, usize)>,
 46     pub unmatched_local: Vec<usize>,
 47     pub unmatched_remote: Vec<usize>,
 48     /// Keys that occur more than once on the local (keyring) side.
 49     pub dup_local: Vec<Key>,
 50     /// Keys that occur more than once on the remote (1Password) side.
 51     pub dup_remote: Vec<Key>,
 52 }
 53 
 54 impl PairPlan {
 55     /// A duplicate anywhere makes the plan unsafe to apply.
 56     pub fn refused(&self) -> bool {
 57         !self.dup_local.is_empty() || !self.dup_remote.is_empty()
 58     }
 59 }
 60 
 61 /// Pure pairing; `pinned` gives locals already stamped with a remote id
 62 /// (from an earlier adopt), honoured before any key match so re-running is
 63 /// idempotent and a retitled entry stays paired.
 64 pub fn pair(local: &[Key], remote: &[Key], pinned: &[(usize, String)], remote_ids: &[String]) -> PairPlan {
 65     let mut plan = PairPlan::default();
 66     let mut local_taken = vec![false; local.len()];
 67     let mut remote_taken = vec![false; remote.len()];
 68 
 69     let remote_by_id: HashMap<&str, usize> = remote_ids.iter().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
 70     for (li, id) in pinned {
 71         if let Some(&ri) = remote_by_id.get(id.as_str()) {
 72             if !remote_taken[ri] {
 73                 plan.pairs.push((*li, ri));
 74                 local_taken[*li] = true;
 75                 remote_taken[ri] = true;
 76             }
 77         }
 78     }
 79 
 80     // One pass per key width: what is unique on both sides under the short
 81     // key pairs; what is not gets a second chance with the url included.
 82     fn pass<K: std::hash::Hash + Eq + Clone>(
 83         key: impl Fn(&Key) -> K,
 84         local: &[Key],
 85         remote: &[Key],
 86         local_taken: &mut [bool],
 87         remote_taken: &mut [bool],
 88         pairs: &mut Vec<(usize, usize)>,
 89     ) {
 90         let count = |keys: &[Key], taken: &[bool]| -> HashMap<K, Vec<usize>> {
 91             let mut m: HashMap<K, Vec<usize>> = HashMap::new();
 92             for (i, k) in keys.iter().enumerate() {
 93                 if !taken[i] {
 94                     m.entry(key(k)).or_default().push(i);
 95                 }
 96             }
 97             m
 98         };
 99         let lmap = count(local, local_taken);
100         let rmap = count(remote, remote_taken);
101         for (i, k) in local.iter().enumerate() {
102             if local_taken[i] {
103                 continue;
104             }
105             let k = key(k);
106             if let (Some([ri]), Some([_])) = (rmap.get(&k).map(Vec::as_slice), lmap.get(&k).map(Vec::as_slice)) {
107                 pairs.push((i, *ri));
108                 local_taken[i] = true;
109                 remote_taken[*ri] = true;
110             }
111         }
112     }
113     pass(Key::short, local, remote, &mut local_taken, &mut remote_taken, &mut plan.pairs);
114     pass(Key::clone, local, remote, &mut local_taken, &mut remote_taken, &mut plan.pairs);
115 
116     // Whatever is still untaken and shares its short key with another
117     // untaken entry on the same side is a duplicate the person must sort.
118     let dups = |keys: &[Key], taken: &[bool]| -> Vec<Key> {
119         let mut m: HashMap<(String, String), Vec<usize>> = HashMap::new();
120         for (i, k) in keys.iter().enumerate() {
121             if !taken[i] {
122                 m.entry(k.short()).or_default().push(i);
123             }
124         }
125         let mut d: Vec<Key> = m
126             .values()
127             .filter(|v| v.len() > 1)
128             .flat_map(|v| v.iter().map(|&i| keys[i].clone()))
129             .collect();
130         d.sort();
131         d.dedup();
132         d
133     };
134     plan.dup_local = dups(local, &local_taken);
135     plan.dup_remote = dups(remote, &remote_taken);
136 
137     plan.unmatched_local = (0..local.len()).filter(|&i| !local_taken[i]).collect();
138     plan.unmatched_remote = (0..remote.len()).filter(|&i| !remote_taken[i]).collect();
139     plan.pairs.sort();
140     plan
141 }
142 
143 /// One keyring login as adopt sees it.
144 struct Local<'a> {
145     item: secret_service::Item<'a>,
146     attrs: HashMap<String, String>,
147     entry: KrEntry,
148 }
149 
150 pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str, dry_run: bool) {
151     let vault = if vault.is_empty() { state.vault.clone() } else { vault.to_string() };
152     let mut remote = OnePassword::new(&vault);
153 
154     let ss = match SecretService::connect(EncryptionType::Dh).await {
155         Ok(ss) => ss,
156         Err(e) => {
157             eprintln!("Secret Service unavailable: {e}");
158             std::process::exit(1);
159         }
160     };
161     // The state-file hash key: minted once, kept in the keyring so the
162     // state file alone leaks nothing (a fresh keyring has none yet).
163     let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
164         Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
165         _ => {
166             let mut k = [0u8; 32];
167             getrandom::getrandom(&mut k).expect("entropy");
168             if !dry_run {
169                 if let Err(e) = crate::keyring_put(&ss, "state-hash-key", "cce-keyring-sync: state hash key", &k).await {
170                     eprintln!("could not store the hash key: {e}");
171                     std::process::exit(1);
172                 }
173             }
174             k
175         }
176     };
177     let col = match ss.get_default_collection().await {
178         Ok(c) => c,
179         Err(e) => {
180             eprintln!("no default collection: {e}");
181             std::process::exit(1);
182         }
183     };
184     if col.is_locked().await.unwrap_or(false) && col.unlock().await.is_err() {
185         eprintln!("collection locked");
186         std::process::exit(1);
187     }
188 
189     // The keyring's logins: anything cce-secrets (or an earlier importer) wrote.
190     let mut locals: Vec<Local<'_>> = Vec::new();
191     match col.get_all_items().await {
192         Ok(items) => {
193             for item in items {
194                 let Ok(attrs) = item.get_attributes().await else { continue };
195                 if attrs.get("application").map(String::as_str) == Some(APP) {
196                     continue;
197                 }
198                 if !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
199                     continue;
200                 }
201                 let entry = KrEntry {
202                     title: item.get_label().await.unwrap_or_default(),
203                     username: attrs.get("UserName").cloned().unwrap_or_default(),
204                     password: String::from_utf8_lossy(&item.get_secret().await.unwrap_or_default()).into_owned(),
205                     url: attrs.get("URL").cloned().unwrap_or_default(),
206                     notes: attrs.get("Notes").cloned().unwrap_or_default(),
207                     group: String::new(), // becomes the vault name once paired
208                     modified: item.get_modified().await.unwrap_or(0),
209                 };
210                 locals.push(Local { item, attrs, entry });
211             }
212         }
213         Err(e) => {
214             eprintln!("listing collection failed: {e}");
215             std::process::exit(1);
216         }
217     }
218     println!("keyring: {} logins", locals.len());
219 
220     println!("1Password: listing{} … (an Authorize dialog may appear)", if vault.is_empty() { "" } else { " the vault" });
221     let summaries: Vec<RemoteSummary> = match remote.list().await {
222         Ok(s) => s,
223         Err(e) => {
224             eprintln!("{e}");
225             std::process::exit(1);
226         }
227     };
228     println!("1Password: {} logins{}", summaries.len(), if vault.is_empty() { String::new() } else { format!(" in {vault}") });
229 
230     let lkeys: Vec<Key> = locals
231         .iter()
232         .map(|l| Key { title: l.entry.title.clone(), username: l.entry.username.clone(), url: l.entry.url.clone() })
233         .collect();
234     let rkeys: Vec<Key> = summaries
235         .iter()
236         .map(|r| Key { title: r.title.clone(), username: r.username.clone(), url: r.url.clone() })
237         .collect();
238     let rids: Vec<String> = summaries.iter().map(|r| r.id.clone()).collect();
239     let pinned: Vec<(usize, String)> = locals
240         .iter()
241         .enumerate()
242         .filter_map(|(i, l)| l.attrs.get(OP_ITEM_ATTR).map(|id| (i, id.clone())))
243         .collect();
244     let plan = pair(&lkeys, &rkeys, &pinned, &rids);
245 
246     let show = |k: &Key| if k.username.is_empty() { k.title.clone() } else { format!("{}  ({})", k.title, k.username) };
247     let when = |t: u64| -> String {
248         // Local time is not worth a dependency; the date alone tells copies apart.
249         let d = t / 86400;
250         let (mut y, mut rem) = (1970u64, d);
251         loop {
252             let len = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
253             if rem < len {
254                 break;
255             }
256             rem -= len;
257             y += 1;
258         }
259         format!("{y}+{rem}d {:02}:{:02}", (t % 86400) / 3600, (t % 3600) / 60)
260     };
261     // Duplicates are the person's call, so show what tells the copies apart.
262     let dup_keys = |d: &[Key]| -> Vec<(String, String)> {
263         let mut v: Vec<(String, String)> = d.iter().map(Key::short).collect();
264         v.dedup();
265         v
266     };
267     if !plan.dup_local.is_empty() {
268         println!("\nDUPLICATE (title, username) in the keyring — same url too, so nothing tells them apart:");
269         for (t, u) in dup_keys(&plan.dup_local) {
270             println!("  {}", show(&Key { title: t.clone(), username: u.clone(), url: String::new() }));
271             for l in locals.iter().filter(|l| l.entry.title == t && l.entry.username == u) {
272                 println!("      url {:<40} modified {}  group {}", l.entry.url, when(l.entry.modified), l.attrs.get("kdbx-group").map(String::as_str).unwrap_or("-"));
273             }
274         }
275     }
276     if !plan.dup_remote.is_empty() {
277         println!("\nDUPLICATE (title, username) in 1Password — archive the stale copies, then retry:");
278         for (t, u) in dup_keys(&plan.dup_remote) {
279             println!("  {}", show(&Key { title: t.clone(), username: u.clone(), url: String::new() }));
280             for r in summaries.iter().filter(|r| r.title == t && r.username == u) {
281                 println!("      url {:<40} updated {}  id {}", r.url, r.updated_raw, r.id);
282             }
283         }
284     }
285     if !plan.unmatched_remote.is_empty() {
286         println!("\nin 1Password only ({}): left alone now; sync would mirror them into the keyring", plan.unmatched_remote.len());
287         for &i in &plan.unmatched_remote {
288             println!("  {}", show(&rkeys[i]));
289         }
290     }
291     if !plan.unmatched_local.is_empty() {
292         println!("\nin the keyring only ({}): left alone now; sync would create them in 1Password", plan.unmatched_local.len());
293         for &i in &plan.unmatched_local {
294             println!("  {}", show(&lkeys[i]));
295         }
296     }
297     println!(
298         "\npairs: {} of {} keyring / {} 1Password ({} already stamped)",
299         plan.pairs.len(),
300         locals.len(),
301         summaries.len(),
302         pinned.len()
303     );
304     if plan.refused() {
305         eprintln!("refusing: duplicates make the pairing ambiguous; nothing changed");
306         std::process::exit(1);
307     }
308 
309     // Field check: the CSV import may have normalised urls or notes. Those
310     // entries get an unknown base timestamp so the first sync fetches and
311     // reconciles them, taking 1Password's value.
312     println!("fetching {} paired items to compare fields …", plan.pairs.len());
313     let mut differing: Vec<usize> = Vec::new();
314     let mut fetched: HashMap<usize, crate::op::RemoteEntry> = HashMap::new();
315     for (n, &(li, ri)) in plan.pairs.iter().enumerate() {
316         if n > 0 && n % 25 == 0 {
317             println!("  {n}/{}", plan.pairs.len());
318         }
319         match remote.fetch(&rids[ri]).await {
320             Ok(e) => {
321                 let l = &locals[li].entry;
322                 if e.password != l.password || e.url != l.url || e.notes != l.notes {
323                     differing.push(li);
324                 }
325                 fetched.insert(ri, e);
326             }
327             Err(err) => {
328                 eprintln!("{err}");
329                 std::process::exit(1);
330             }
331         }
332     }
333     if !differing.is_empty() {
334         println!("\nfields differ on {} paired entries (password/url/notes); the first sync takes 1Password's value:", differing.len());
335         for &li in &differing {
336             let l = &locals[li].entry;
337             let r = &fetched[&plan.pairs.iter().find(|(a, _)| *a == li).unwrap().1];
338             let mut what = Vec::new();
339             if r.password != l.password {
340                 what.push("password");
341             }
342             if r.url != l.url {
343                 what.push("url");
344             }
345             if r.notes != l.notes {
346                 what.push("notes");
347             }
348             println!("  {}  [{}]", show(&lkeys[li]), what.join(", "));
349         }
350     }
351 
352     if dry_run {
353         println!("\ndry run — nothing changed");
354         return;
355     }
356 
357     // ---- apply: stamp, then state ----
358     let mut stamped = 0usize;
359     let mut entries: HashMap<String, EntryState> = HashMap::new();
360     for &(li, ri) in &plan.pairs {
361         let l = &locals[li];
362         let r = &fetched[&ri];
363         let mut attrs: HashMap<&str, &str> = l.attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
364         attrs.insert(OP_ITEM_ATTR, r.id.as_str());
365         attrs.insert(OP_VAULT_ATTR, r.vault.as_str());
366         if let Err(e) = l.item.set_attributes(attrs).await {
367             eprintln!("  could not stamp {}: {e}", l.entry.title);
368             continue;
369         }
370         stamped += 1;
371         let mut base = l.entry.clone();
372         base.group = r.vault.clone();
373         entries.insert(
374             r.id.clone(),
375             EntryState {
376                 h: base.hash(&hash_key),
377                 keyring_modified: l.entry.modified,
378                 op_updated_at: if differing.contains(&li) { String::new() } else { r.updated_raw.clone() },
379             },
380         );
381     }
382 
383     // A pre-existing base of another shape is kept aside, not overwritten.
384     if state.backend != "onepassword" && state_path.exists() {
385         let backup = state_dir().join(format!("state.json.old-{}", now_unix()));
386         if std::fs::copy(state_path, &backup).is_ok() {
387             println!("previous sync state backed up to {}", backup.display());
388         }
389     }
390     state.version = 2;
391     state.backend = "onepassword".to_string();
392     state.vault = vault.clone();
393     state.entries = entries;
394     state.last_run = now_unix();
395     write_state(state_path, &state);
396     crate::journal_append(&format!("{} adopt stamped {stamped} entries (1Password, vault {vault})\n", now_unix()));
397     println!("\nadopted: {stamped} entries stamped; backend is now 1Password");
398 }
399 
400 #[cfg(test)]
401 mod tests {
402     use super::*;
403 
404     fn k(t: &str, u: &str) -> Key {
405         Key { title: t.to_string(), username: u.to_string(), url: String::new() }
406     }
407 
408     fn ku(t: &str, u: &str, url: &str) -> Key {
409         Key { title: t.to_string(), username: u.to_string(), url: url.to_string() }
410     }
411 
412     #[test]
413     fn the_url_breaks_a_tie_when_it_can() {
414         let local = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://b.example")];
415         let remote = vec![ku("MS", "me", "https://b.example"), ku("MS", "me", "https://a.example")];
416         let ids = vec!["r0".into(), "r1".into()];
417         let p = pair(&local, &remote, &[], &ids);
418         assert_eq!(p.pairs, vec![(0, 1), (1, 0)]);
419         assert!(!p.refused());
420     }
421 
422     #[test]
423     fn identical_urls_stay_ambiguous() {
424         let local = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://a.example")];
425         let remote = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://a.example")];
426         let ids = vec!["r0".into(), "r1".into()];
427         let p = pair(&local, &remote, &[], &ids);
428         assert!(p.refused());
429         assert!(p.pairs.is_empty());
430         assert_eq!(p.dup_local.len(), 1, "reported once per key, not per copy");
431     }
432 
433     #[test]
434     fn exact_keys_pair_and_the_rest_are_reported() {
435         let local = vec![k("GitHub", "me"), k("Bank", "me"), k("Old", "x")];
436         let remote = vec![k("Bank", "me"), k("GitHub", "me"), k("New", "y")];
437         let ids = vec!["r0".into(), "r1".into(), "r2".into()];
438         let p = pair(&local, &remote, &[], &ids);
439         assert_eq!(p.pairs, vec![(0, 1), (1, 0)]);
440         assert_eq!(p.unmatched_local, vec![2]);
441         assert_eq!(p.unmatched_remote, vec![2]);
442         assert!(!p.refused());
443     }
444 
445     #[test]
446     fn matching_is_case_sensitive_and_username_aware() {
447         let local = vec![k("GitHub", "me"), k("Mail", "a")];
448         let remote = vec![k("github", "me"), k("Mail", "b")];
449         let ids = vec!["r0".into(), "r1".into()];
450         let p = pair(&local, &remote, &[], &ids);
451         assert!(p.pairs.is_empty());
452         assert_eq!(p.unmatched_local, vec![0, 1]);
453         assert_eq!(p.unmatched_remote, vec![0, 1]);
454     }
455 
456     #[test]
457     fn a_duplicate_on_either_side_refuses_that_key_and_the_run() {
458         let local = vec![k("Bank", "me"), k("Bank", "me"), k("Mail", "a")];
459         let remote = vec![k("Bank", "me"), k("Mail", "a"), k("Mail", "a")];
460         let ids = vec!["r0".into(), "r1".into(), "r2".into()];
461         let p = pair(&local, &remote, &[], &ids);
462         assert!(p.refused());
463         assert_eq!(p.dup_local, vec![k("Bank", "me")]);
464         assert_eq!(p.dup_remote, vec![k("Mail", "a")]);
465         assert!(p.pairs.is_empty(), "an ambiguous key never pairs, even its single-sided partner");
466         assert_eq!(p.unmatched_local, vec![0, 1, 2]);
467         assert_eq!(p.unmatched_remote, vec![0, 1, 2]);
468     }
469 
470     #[test]
471     fn a_pinned_id_wins_over_the_key_and_survives_a_retitle() {
472         let local = vec![k("Bank (renamed)", "me"), k("Bank", "me")];
473         let remote = vec![k("Bank", "me")];
474         let ids = vec!["r0".into()];
475         // local 0 was stamped r0 in an earlier run and then retitled keyring-side.
476         let p = pair(&local, &remote, &[(0, "r0".into())], &ids);
477         assert_eq!(p.pairs, vec![(0, 0)]);
478         assert_eq!(p.unmatched_local, vec![1], "the key match loses to the pin");
479         assert!(p.unmatched_remote.is_empty());
480     }
481 
482     #[test]
483     fn a_pin_to_a_vanished_id_falls_back_to_the_key() {
484         let local = vec![k("Bank", "me")];
485         let remote = vec![k("Bank", "me")];
486         let ids = vec!["r-new".into()];
487         let p = pair(&local, &remote, &[(0, "r-old".into())], &ids);
488         assert_eq!(p.pairs, vec![(0, 0)]);
489     }
490 }