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

src/bin/cce-keyring-sync/adopt.rs (20.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     // As in `sync_remote`: a read the keyring cannot answer stops the run
185     // rather than standing in as an empty value, which would pair an item
186     // under a blank password.
187     let locked = match col.is_locked().await {
188         Ok(l) => l,
189         Err(e) => {
190             eprintln!("cannot tell whether the keyring is locked: {e}");
191             std::process::exit(1);
192         }
193     };
194     if locked && col.unlock().await.is_err() {
195         eprintln!("collection locked");
196         std::process::exit(1);
197     }
198 
199     // The keyring's logins: anything cce-secrets (or an earlier importer) wrote.
200     let mut locals: Vec<Local<'_>> = Vec::new();
201     match col.get_all_items().await {
202         Ok(items) => {
203             for item in items {
204                 let attrs = item.get_attributes().await.unwrap_or_else(|e| {
205                     eprintln!("reading an item's attributes failed: {e}; nothing adopted");
206                     std::process::exit(1);
207                 });
208                 if attrs.get("application").map(String::as_str) == Some(APP) {
209                     continue;
210                 }
211                 if !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
212                     continue;
213                 }
214                 let unreadable = |what: &str, e: &dyn std::fmt::Display| -> ! {
215                     eprintln!("reading the {what} of a keyring login failed: {e}; nothing adopted");
216                     std::process::exit(1);
217                 };
218                 let title = item.get_label().await.unwrap_or_else(|e| unreadable("title", &e));
219                 let secret = item.get_secret().await.unwrap_or_else(|e| unreadable("password", &e));
220                 let modified = item.get_modified().await.unwrap_or_else(|e| unreadable("modified time", &e));
221                 let entry = KrEntry {
222                     title,
223                     username: attrs.get("UserName").cloned().unwrap_or_default(),
224                     password: String::from_utf8_lossy(&secret).into_owned(),
225                     url: attrs.get("URL").cloned().unwrap_or_default(),
226                     notes: attrs.get("Notes").cloned().unwrap_or_default(),
227                     group: String::new(), // becomes the vault name once paired
228                     modified,
229                 };
230                 locals.push(Local { item, attrs, entry });
231             }
232         }
233         Err(e) => {
234             eprintln!("listing collection failed: {e}");
235             std::process::exit(1);
236         }
237     }
238     println!("keyring: {} logins", locals.len());
239 
240     println!("1Password: listing{} … (an Authorize dialog may appear)", if vault.is_empty() { "" } else { " the vault" });
241     let summaries: Vec<RemoteSummary> = match remote.list().await {
242         Ok(s) => s,
243         Err(e) => {
244             eprintln!("{e}");
245             std::process::exit(1);
246         }
247     };
248     println!("1Password: {} logins{}", summaries.len(), if vault.is_empty() { String::new() } else { format!(" in {vault}") });
249 
250     let lkeys: Vec<Key> = locals
251         .iter()
252         .map(|l| Key { title: l.entry.title.clone(), username: l.entry.username.clone(), url: l.entry.url.clone() })
253         .collect();
254     let rkeys: Vec<Key> = summaries
255         .iter()
256         .map(|r| Key { title: r.title.clone(), username: r.username.clone(), url: r.url.clone() })
257         .collect();
258     let rids: Vec<String> = summaries.iter().map(|r| r.id.clone()).collect();
259     let pinned: Vec<(usize, String)> = locals
260         .iter()
261         .enumerate()
262         .filter_map(|(i, l)| l.attrs.get(OP_ITEM_ATTR).map(|id| (i, id.clone())))
263         .collect();
264     let plan = pair(&lkeys, &rkeys, &pinned, &rids);
265 
266     let show = |k: &Key| if k.username.is_empty() { k.title.clone() } else { format!("{}  ({})", k.title, k.username) };
267     let when = |t: u64| -> String {
268         // Local time is not worth a dependency; the date alone tells copies apart.
269         let d = t / 86400;
270         let (mut y, mut rem) = (1970u64, d);
271         loop {
272             let len = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
273             if rem < len {
274                 break;
275             }
276             rem -= len;
277             y += 1;
278         }
279         format!("{y}+{rem}d {:02}:{:02}", (t % 86400) / 3600, (t % 3600) / 60)
280     };
281     // Duplicates are the person's call, so show what tells the copies apart.
282     let dup_keys = |d: &[Key]| -> Vec<(String, String)> {
283         let mut v: Vec<(String, String)> = d.iter().map(Key::short).collect();
284         v.dedup();
285         v
286     };
287     if !plan.dup_local.is_empty() {
288         println!("\nDUPLICATE (title, username) in the keyring — same url too, so nothing tells them apart:");
289         for (t, u) in dup_keys(&plan.dup_local) {
290             println!("  {}", show(&Key { title: t.clone(), username: u.clone(), url: String::new() }));
291             for l in locals.iter().filter(|l| l.entry.title == t && l.entry.username == u) {
292                 println!("      url {:<40} modified {}  group {}", l.entry.url, when(l.entry.modified), l.attrs.get("kdbx-group").map(String::as_str).unwrap_or("-"));
293             }
294         }
295     }
296     if !plan.dup_remote.is_empty() {
297         println!("\nDUPLICATE (title, username) in 1Password — archive the stale copies, then retry:");
298         for (t, u) in dup_keys(&plan.dup_remote) {
299             println!("  {}", show(&Key { title: t.clone(), username: u.clone(), url: String::new() }));
300             for r in summaries.iter().filter(|r| r.title == t && r.username == u) {
301                 println!("      url {:<40} updated {}  id {}", r.url, r.updated_raw, r.id);
302             }
303         }
304     }
305     if !plan.unmatched_remote.is_empty() {
306         println!("\nin 1Password only ({}): left alone now; sync would mirror them into the keyring", plan.unmatched_remote.len());
307         for &i in &plan.unmatched_remote {
308             println!("  {}", show(&rkeys[i]));
309         }
310     }
311     if !plan.unmatched_local.is_empty() {
312         println!("\nin the keyring only ({}): left alone now; sync would create them in 1Password", plan.unmatched_local.len());
313         for &i in &plan.unmatched_local {
314             println!("  {}", show(&lkeys[i]));
315         }
316     }
317     println!(
318         "\npairs: {} of {} keyring / {} 1Password ({} already stamped)",
319         plan.pairs.len(),
320         locals.len(),
321         summaries.len(),
322         pinned.len()
323     );
324     if plan.refused() {
325         eprintln!("refusing: duplicates make the pairing ambiguous; nothing changed");
326         std::process::exit(1);
327     }
328 
329     // Field check: the CSV import may have normalised urls or notes. Those
330     // entries get an unknown base timestamp so the first sync fetches and
331     // reconciles them, taking 1Password's value.
332     println!("fetching {} paired items to compare fields …", plan.pairs.len());
333     let mut differing: Vec<usize> = Vec::new();
334     let mut fetched: HashMap<usize, crate::op::RemoteEntry> = HashMap::new();
335     for (n, &(li, ri)) in plan.pairs.iter().enumerate() {
336         if n > 0 && n % 25 == 0 {
337             println!("  {n}/{}", plan.pairs.len());
338         }
339         match remote.fetch(&rids[ri]).await {
340             Ok(e) => {
341                 let l = &locals[li].entry;
342                 if e.password != l.password || e.url != l.url || e.notes != l.notes {
343                     differing.push(li);
344                 }
345                 fetched.insert(ri, e);
346             }
347             Err(err) => {
348                 eprintln!("{err}");
349                 std::process::exit(1);
350             }
351         }
352     }
353     if !differing.is_empty() {
354         println!("\nfields differ on {} paired entries (password/url/notes); the first sync takes 1Password's value:", differing.len());
355         for &li in &differing {
356             let l = &locals[li].entry;
357             let r = &fetched[&plan.pairs.iter().find(|(a, _)| *a == li).unwrap().1];
358             let mut what = Vec::new();
359             if r.password != l.password {
360                 what.push("password");
361             }
362             if r.url != l.url {
363                 what.push("url");
364             }
365             if r.notes != l.notes {
366                 what.push("notes");
367             }
368             println!("  {}  [{}]", show(&lkeys[li]), what.join(", "));
369         }
370     }
371 
372     if dry_run {
373         println!("\ndry run — nothing changed");
374         return;
375     }
376 
377     // ---- apply: stamp, then state ----
378     let mut stamped = 0usize;
379     let mut entries: HashMap<String, EntryState> = HashMap::new();
380     for &(li, ri) in &plan.pairs {
381         let l = &locals[li];
382         let r = &fetched[&ri];
383         let mut attrs: HashMap<&str, &str> = l.attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
384         attrs.insert(OP_ITEM_ATTR, r.id.as_str());
385         attrs.insert(OP_VAULT_ATTR, r.vault.as_str());
386         if let Err(e) = l.item.set_attributes(attrs).await {
387             eprintln!("  could not stamp {}: {e}", l.entry.title);
388             continue;
389         }
390         stamped += 1;
391         let mut base = l.entry.clone();
392         base.group = r.vault.clone();
393         entries.insert(
394             r.id.clone(),
395             EntryState {
396                 h: base.hash(&hash_key),
397                 keyring_modified: l.entry.modified,
398                 op_updated_at: if differing.contains(&li) { String::new() } else { r.updated_raw.clone() },
399             },
400         );
401     }
402 
403     // A pre-existing base of another shape is kept aside, not overwritten.
404     if state.backend != "onepassword" && state_path.exists() {
405         let backup = state_dir().join(format!("state.json.old-{}", now_unix()));
406         if std::fs::copy(state_path, &backup).is_ok() {
407             println!("previous sync state backed up to {}", backup.display());
408         }
409     }
410     state.version = 2;
411     state.backend = "onepassword".to_string();
412     state.vault = vault.clone();
413     state.entries = entries;
414     state.last_run = now_unix();
415     write_state(state_path, &state);
416     crate::journal_append(&format!("{} adopt stamped {stamped} entries (1Password, vault {vault})\n", now_unix()));
417     println!("\nadopted: {stamped} entries stamped; backend is now 1Password");
418 }
419 
420 #[cfg(test)]
421 mod tests {
422     use super::*;
423 
424     fn k(t: &str, u: &str) -> Key {
425         Key { title: t.to_string(), username: u.to_string(), url: String::new() }
426     }
427 
428     fn ku(t: &str, u: &str, url: &str) -> Key {
429         Key { title: t.to_string(), username: u.to_string(), url: url.to_string() }
430     }
431 
432     #[test]
433     fn the_url_breaks_a_tie_when_it_can() {
434         let local = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://b.example")];
435         let remote = vec![ku("MS", "me", "https://b.example"), ku("MS", "me", "https://a.example")];
436         let ids = vec!["r0".into(), "r1".into()];
437         let p = pair(&local, &remote, &[], &ids);
438         assert_eq!(p.pairs, vec![(0, 1), (1, 0)]);
439         assert!(!p.refused());
440     }
441 
442     #[test]
443     fn identical_urls_stay_ambiguous() {
444         let local = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://a.example")];
445         let remote = vec![ku("MS", "me", "https://a.example"), ku("MS", "me", "https://a.example")];
446         let ids = vec!["r0".into(), "r1".into()];
447         let p = pair(&local, &remote, &[], &ids);
448         assert!(p.refused());
449         assert!(p.pairs.is_empty());
450         assert_eq!(p.dup_local.len(), 1, "reported once per key, not per copy");
451     }
452 
453     #[test]
454     fn exact_keys_pair_and_the_rest_are_reported() {
455         let local = vec![k("GitHub", "me"), k("Bank", "me"), k("Old", "x")];
456         let remote = vec![k("Bank", "me"), k("GitHub", "me"), k("New", "y")];
457         let ids = vec!["r0".into(), "r1".into(), "r2".into()];
458         let p = pair(&local, &remote, &[], &ids);
459         assert_eq!(p.pairs, vec![(0, 1), (1, 0)]);
460         assert_eq!(p.unmatched_local, vec![2]);
461         assert_eq!(p.unmatched_remote, vec![2]);
462         assert!(!p.refused());
463     }
464 
465     #[test]
466     fn matching_is_case_sensitive_and_username_aware() {
467         let local = vec![k("GitHub", "me"), k("Mail", "a")];
468         let remote = vec![k("github", "me"), k("Mail", "b")];
469         let ids = vec!["r0".into(), "r1".into()];
470         let p = pair(&local, &remote, &[], &ids);
471         assert!(p.pairs.is_empty());
472         assert_eq!(p.unmatched_local, vec![0, 1]);
473         assert_eq!(p.unmatched_remote, vec![0, 1]);
474     }
475 
476     #[test]
477     fn a_duplicate_on_either_side_refuses_that_key_and_the_run() {
478         let local = vec![k("Bank", "me"), k("Bank", "me"), k("Mail", "a")];
479         let remote = vec![k("Bank", "me"), k("Mail", "a"), k("Mail", "a")];
480         let ids = vec!["r0".into(), "r1".into(), "r2".into()];
481         let p = pair(&local, &remote, &[], &ids);
482         assert!(p.refused());
483         assert_eq!(p.dup_local, vec![k("Bank", "me")]);
484         assert_eq!(p.dup_remote, vec![k("Mail", "a")]);
485         assert!(p.pairs.is_empty(), "an ambiguous key never pairs, even its single-sided partner");
486         assert_eq!(p.unmatched_local, vec![0, 1, 2]);
487         assert_eq!(p.unmatched_remote, vec![0, 1, 2]);
488     }
489 
490     #[test]
491     fn a_pinned_id_wins_over_the_key_and_survives_a_retitle() {
492         let local = vec![k("Bank (renamed)", "me"), k("Bank", "me")];
493         let remote = vec![k("Bank", "me")];
494         let ids = vec!["r0".into()];
495         // local 0 was stamped r0 in an earlier run and then retitled keyring-side.
496         let p = pair(&local, &remote, &[(0, "r0".into())], &ids);
497         assert_eq!(p.pairs, vec![(0, 0)]);
498         assert_eq!(p.unmatched_local, vec![1], "the key match loses to the pin");
499         assert!(p.unmatched_remote.is_empty());
500     }
501 
502     #[test]
503     fn a_pin_to_a_vanished_id_falls_back_to_the_key() {
504         let local = vec![k("Bank", "me")];
505         let remote = vec![k("Bank", "me")];
506         let ids = vec!["r-new".into()];
507         let p = pair(&local, &remote, &[(0, "r-old".into())], &ids);
508         assert_eq!(p.pairs, vec![(0, 0)]);
509     }
510 }