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 }