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 }