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

src/bin/cce-keyring-sync/op.rs (20.1K)

  1 //! The interchange seam, and 1Password behind it.
  2 //!
  3 //! KEYRING-SYNC.md ("Scoping: 1Password as the interchange") is the design.
  4 //! Everything 1Password-specific is a child `op` process with JSON on stdout
  5 //! and, for writes, a JSON item template on stdin — **never a value on
  6 //! argv**, which every same-user process can read. Each spawn runs under
  7 //! [`OP_TIMEOUT`]: an unanswered Authorize dialog holds `op` for 60 s before
  8 //! it gives up, and a wedged app must not hold a tick forever.
  9 //!
 10 //! The `Interchange` trait is what the merge loop (sync.rs) calls; 1Password
 11 //! is its only implementation since the kdbx backend retired.
 12 
 13 use std::time::Duration;
 14 
 15 use serde_json::{json, Value};
 16 
 17 /// Longer than the app's own 60-second dialog timeout, so a dismissed
 18 /// prompt reports itself as such instead of as a kill.
 19 pub const OP_TIMEOUT: Duration = Duration::from_secs(75);
 20 
 21 /// The text `op` prints when the Authorize dialog timed out unanswered.
 22 const DISMISSED: &str = "authorization prompt dismissed";
 23 
 24 /// One remote entry, whole: the six fields the merge hashes plus identity.
 25 #[derive(Clone, Debug, PartialEq, Default)]
 26 pub struct RemoteEntry {
 27     pub id: String,
 28     /// 1Password: the vault name (stored keyring-side as `op-vault`).
 29     pub vault: String,
 30     pub title: String,
 31     pub username: String,
 32     pub password: String,
 33     pub url: String,
 34     pub notes: String,
 35     /// Server-side modification time, unix seconds (0 when unparseable).
 36     pub updated: i64,
 37     /// `updated` as canonical RFC 3339 UTC text (`2026-09-21T16:27:42Z`),
 38     /// the form the base snapshot stores. Canonical because `op` itself is
 39     /// not consistent: `item list` prints UTC to the second, `item get` and
 40     /// `item edit` print local time with an offset and nanoseconds, and a
 41     /// base written from one must still match a list read from the other.
 42     pub updated_raw: String,
 43 }
 44 
 45 /// What `list` returns: everything but the secret fields, so a quiet run
 46 /// never touches a password.
 47 #[derive(Clone, Debug, PartialEq, Default)]
 48 pub struct RemoteSummary {
 49     pub id: String,
 50     pub vault: String,
 51     pub title: String,
 52     pub username: String,
 53     pub url: String,
 54     pub updated: i64,
 55     pub updated_raw: String,
 56 }
 57 
 58 /// The cross-machine store the keyring is mirrored against.
 59 pub trait Interchange {
 60     /// Every login the store holds — no secrets.
 61     async fn list(&mut self) -> Result<Vec<RemoteSummary>, String>;
 62     /// One entry in full.
 63     async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>;
 64     /// Store a new entry; returns its id and its timestamp text. `e.id` is ignored.
 65     async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String>;
 66     /// Overwrite an existing entry's synced fields, leaving the rest alone;
 67     /// returns the entry's new timestamp text, so the base can record it
 68     /// without another fetch.
 69     async fn update(&mut self, e: &RemoteEntry) -> Result<String, String>;
 70     /// Soft-delete: 1Password's Archive.
 71     async fn recycle(&mut self, id: &str) -> Result<(), String>;
 72 }
 73 
 74 /// True when the error text is the app's dialog timing out — a refusal to
 75 /// back off from, not a fault to log as one.
 76 pub fn is_dismissed(err: &str) -> bool {
 77     err.contains(DISMISSED)
 78 }
 79 
 80 // ───────────────────────────── 1Password ─────────────────────────────
 81 
 82 pub struct OnePassword {
 83     /// Vault to list from and create into. Empty means every vault `op`
 84     /// can read; creates then need a name, so `create` refuses.
 85     pub vault: String,
 86     /// `--account`, for a person with several signed in. Empty: op's default.
 87     pub account: String,
 88 }
 89 
 90 impl OnePassword {
 91     pub fn new(vault: &str) -> Self {
 92         OnePassword { vault: vault.to_string(), account: String::new() }
 93     }
 94 
 95     /// Spawn `op` as a direct child (the authorization is keyed to *our*
 96     /// pid as its parent — never via a shell, setsid, or a double fork),
 97     /// feed `stdin`, and return stdout. Stderr's last line is the error.
 98     async fn run(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
 99         use tokio::io::AsyncWriteExt;
100         let mut cmd = tokio::process::Command::new("op");
101         cmd.args(args).arg("--format").arg("json").arg("--no-color");
102         if !self.account.is_empty() {
103             cmd.arg("--account").arg(&self.account);
104         }
105         cmd.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
106             .stdout(std::process::Stdio::piped())
107             .stderr(std::process::Stdio::piped())
108             .kill_on_drop(true);
109         let mut child = cmd.spawn().map_err(|e| format!("op not runnable: {e}"))?;
110         if let Some(bytes) = stdin {
111             let mut pipe = child.stdin.take().expect("piped stdin");
112             // A closed pipe (op exited early) is reported by wait, not here.
113             let _ = pipe.write_all(&bytes).await;
114             drop(pipe);
115         }
116         let out = match tokio::time::timeout(OP_TIMEOUT, child.wait_with_output()).await {
117             Ok(Ok(out)) => out,
118             Ok(Err(e)) => return Err(format!("op failed to run: {e}")),
119             Err(_) => return Err(format!("op timed out after {}s (app wedged?)", OP_TIMEOUT.as_secs())),
120         };
121         if out.status.success() {
122             return Ok(out.stdout);
123         }
124         let err = String::from_utf8_lossy(&out.stderr);
125         let last = err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
126         // op prefixes "[ERROR] 2026/09/21 10:15:39 "; keep what follows.
127         let msg = last.splitn(4, ' ').nth(3).unwrap_or(last);
128         Err(format!("op {}: {msg}", args.first().copied().unwrap_or("")))
129     }
130 
131     async fn run_json(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Value, String> {
132         let bytes = self.run(args, stdin).await?;
133         serde_json::from_slice(&bytes).map_err(|e| format!("op {}: unparseable JSON: {e}", args.join(" ")))
134     }
135 }
136 
137 impl Interchange for OnePassword {
138     async fn list(&mut self) -> Result<Vec<RemoteSummary>, String> {
139         let mut args = vec!["item", "list", "--categories", "Login"];
140         if !self.vault.is_empty() {
141             args.extend(["--vault", self.vault.as_str()]);
142         }
143         let v = self.run_json(&args, None).await?;
144         let items = v.as_array().ok_or("op item list: not an array")?;
145         Ok(items.iter().map(summary_from_json).collect())
146     }
147 
148     async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String> {
149         let v = self.run_json(&["item", "get", id], None).await?;
150         Ok(entry_from_json(&v))
151     }
152 
153     async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String> {
154         if self.vault.is_empty() {
155             return Err("no vault configured for new entries (adopt --vault <name>)".into());
156         }
157         let template = serde_json::to_vec(&create_template(e)).unwrap();
158         let v = self
159             .run_json(&["item", "create", "--vault", self.vault.as_str(), "-"], Some(template))
160             .await?;
161         let id = v.get("id").and_then(Value::as_str).ok_or("op item create: no id in reply")?;
162         Ok((id.to_string(), updated_of(&v).1))
163     }
164 
165     async fn update(&mut self, e: &RemoteEntry) -> Result<String, String> {
166         // Round-trip the whole item so sections, custom fields and tags
167         // survive; only the synced fields are rewritten.
168         let mut v = self.run_json(&["item", "get", &e.id], None).await?;
169         apply_entry(&mut v, e);
170         let body = serde_json::to_vec(&v).unwrap();
171         let reply = self.run_json(&["item", "edit", &e.id], Some(body)).await?;
172         Ok(updated_of(&reply).1)
173     }
174 
175     async fn recycle(&mut self, id: &str) -> Result<(), String> {
176         // `delete --archive` prints nothing; run, not run_json.
177         self.run(&["item", "delete", id, "--archive"], None).await.map(|_| ())
178     }
179 }
180 
181 // ───────────────────────────── JSON shapes ─────────────────────────────
182 //
183 // Captured from op 2.39.0 (2026-09-21). `item list` gives id, title,
184 // vault{id,name}, category, urls[{href,primary}], additional_information
185 // (the username for logins), created_at, updated_at. `item get` adds
186 // fields[{id,type,purpose,label,value,…}] with purpose USERNAME / PASSWORD /
187 // NOTES; a NOTES field with no value has no `value` key at all.
188 
189 fn s(v: &Value, key: &str) -> String {
190     v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
191 }
192 
193 fn primary_url(v: &Value) -> String {
194     let Some(urls) = v.get("urls").and_then(Value::as_array) else { return String::new() };
195     urls.iter()
196         .find(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
197         .or_else(|| urls.first())
198         .map(|u| s(u, "href"))
199         .unwrap_or_default()
200 }
201 
202 fn field_by_purpose<'a>(v: &'a Value, purpose: &str) -> Option<&'a Value> {
203     v.get("fields")?
204         .as_array()?
205         .iter()
206         .find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose))
207 }
208 
209 /// The (unix, canonical text) pair for an item's `updated_at`.
210 pub fn updated_of(v: &Value) -> (i64, String) {
211     match parse_rfc3339(&s(v, "updated_at")) {
212         Some(t) => (t, format_rfc3339(t)),
213         None => (0, String::new()),
214     }
215 }
216 
217 pub fn summary_from_json(v: &Value) -> RemoteSummary {
218     let (updated, updated_raw) = updated_of(v);
219     RemoteSummary {
220         id: s(v, "id"),
221         vault: v.get("vault").map(|x| s(x, "name")).unwrap_or_default(),
222         title: s(v, "title"),
223         username: s(v, "additional_information"),
224         url: primary_url(v),
225         updated,
226         updated_raw,
227     }
228 }
229 
230 pub fn entry_from_json(v: &Value) -> RemoteEntry {
231     let sum = summary_from_json(v);
232     let field = |p: &str| field_by_purpose(v, p).map(|f| s(f, "value")).unwrap_or_default();
233     RemoteEntry {
234         id: sum.id,
235         vault: sum.vault,
236         title: sum.title,
237         // The field is authoritative; additional_information is its echo.
238         username: field("USERNAME"),
239         password: field("PASSWORD"),
240         url: sum.url,
241         notes: field("NOTES"),
242         updated: sum.updated,
243         updated_raw: sum.updated_raw,
244     }
245 }
246 
247 /// The Login template `op item template get Login` prints, filled in.
248 pub fn create_template(e: &RemoteEntry) -> Value {
249     let mut t = json!({
250         "title": e.title,
251         "category": "LOGIN",
252         "fields": [
253             {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": e.username},
254             {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": e.password},
255             {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain", "value": e.notes},
256         ]
257     });
258     if !e.url.is_empty() {
259         t["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
260     }
261     t
262 }
263 
264 /// Rewrite the synced fields of a fetched item in place.
265 pub fn apply_entry(v: &mut Value, e: &RemoteEntry) {
266     v["title"] = json!(e.title);
267     // The primary URL is replaced (or added); other URLs are left alone.
268     let urls = v.get_mut("urls").and_then(Value::as_array_mut);
269     match urls {
270         Some(list) if !list.is_empty() => {
271             let idx = list
272                 .iter()
273                 .position(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
274                 .unwrap_or(0);
275             if e.url.is_empty() {
276                 list.remove(idx);
277             } else {
278                 list[idx]["href"] = json!(e.url);
279             }
280         }
281         _ => {
282             if !e.url.is_empty() {
283                 v["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
284             }
285         }
286     }
287     let set = |v: &mut Value, purpose: &str, id: &str, kind: &str, value: &str| {
288         let fields = v
289             .as_object_mut()
290             .expect("item object")
291             .entry("fields")
292             .or_insert_with(|| json!([]));
293         let list = fields.as_array_mut().expect("fields array");
294         match list.iter_mut().find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose)) {
295             Some(f) => f["value"] = json!(value),
296             None => list.push(json!({"id": id, "type": kind, "purpose": purpose, "label": id, "value": value})),
297         }
298     };
299     set(v, "USERNAME", "username", "STRING", &e.username);
300     set(v, "PASSWORD", "password", "CONCEALED", &e.password);
301     set(v, "NOTES", "notesPlain", "STRING", &e.notes);
302 }
303 
304 /// RFC 3339 → unix seconds: `2026-09-21T15:41:14Z`, or with a fraction,
305 /// or with a `±HH:MM` offset (what `op item get`/`edit` print). Fractions
306 /// are dropped: the list side only has seconds, and the two must agree.
307 pub fn parse_rfc3339(t: &str) -> Option<i64> {
308     let (date, rest) = t.split_once('T')?;
309     let (time, offset_secs) = if let Some(r) = rest.strip_suffix('Z') {
310         (r, 0)
311     } else {
312         let i = rest.rfind(['+', '-'])?;
313         let (r, off) = rest.split_at(i);
314         let sign = if off.starts_with('-') { -1 } else { 1 };
315         let (oh, om) = off[1..].split_once(':')?;
316         (r, sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60))
317     };
318     let mut d = date.split('-').map(|p| p.parse::<i64>().ok());
319     let (y, m, day) = (d.next()??, d.next()??, d.next()??);
320     let time = time.split('.').next()?;
321     let mut c = time.split(':').map(|p| p.parse::<i64>().ok());
322     let (h, mi, sec) = (c.next()??, c.next()??, c.next()??);
323     Some(days_from_civil(y, m, day) * 86400 + h * 3600 + mi * 60 + sec - offset_secs)
324 }
325 
326 /// unix seconds → `2026-09-21T16:27:42Z`, the canonical base form.
327 pub fn format_rfc3339(t: i64) -> String {
328     let days = t.div_euclid(86400);
329     let rem = t.rem_euclid(86400);
330     let (y, m, d) = civil_from_days(days);
331     format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
332 }
333 
334 // Howard Hinnant's civil-date algorithms.
335 fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
336     let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
337     let era = y.div_euclid(400);
338     let yoe = y - era * 400;
339     let doy = (153 * m + 2) / 5 + d - 1;
340     let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
341     era * 146097 + doe - 719468
342 }
343 
344 fn civil_from_days(z: i64) -> (i64, i64, i64) {
345     let z = z + 719468;
346     let era = z.div_euclid(146097);
347     let doe = z - era * 146097;
348     let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
349     let y = yoe + era * 400;
350     let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
351     let mp = (5 * doy + 2) / 153;
352     let d = doy - (153 * mp + 2) / 5 + 1;
353     let m = if mp < 10 { mp + 3 } else { mp - 9 };
354     (if m <= 2 { y + 1 } else { y }, m, d)
355 }
356 
357 #[cfg(test)]
358 mod tests {
359     use super::*;
360 
361     const LIST_ITEM: &str = r#"{
362         "id": "abc", "title": "Example", "tags": [], "version": 1,
363         "vault": {"id": "v1", "name": "Personal"}, "category": "LOGIN",
364         "created_at": "2026-09-21T15:41:14Z", "updated_at": "2026-09-21T15:41:14Z",
365         "additional_information": "someone",
366         "urls": [{"href": "https://old.example.com"}, {"primary": true, "href": "https://example.com"}]
367     }"#;
368 
369     #[test]
370     fn a_list_item_yields_a_summary_with_no_secret() {
371         let v: Value = serde_json::from_str(LIST_ITEM).unwrap();
372         let s = summary_from_json(&v);
373         assert_eq!(s.id, "abc");
374         assert_eq!(s.vault, "Personal");
375         assert_eq!(s.username, "someone");
376         assert_eq!(s.url, "https://example.com", "the primary url wins over the first");
377         assert_eq!(s.updated, 1790005274);
378         assert_eq!(s.updated_raw, "2026-09-21T15:41:14Z");
379     }
380 
381     #[test]
382     fn a_full_item_reads_its_fields_by_purpose() {
383         let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
384         v["fields"] = json!([
385             {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
386             {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "hunter2"},
387             {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain"}
388         ]);
389         let e = entry_from_json(&v);
390         assert_eq!(e.password, "hunter2");
391         assert_eq!(e.notes, "", "a notes field without a value is empty, not missing");
392         assert_eq!(e.username, "someone");
393     }
394 
395     #[test]
396     fn a_missing_url_list_is_empty() {
397         let v: Value = json!({"id": "x", "title": "t", "updated_at": "nope"});
398         let s = summary_from_json(&v);
399         assert_eq!(s.url, "");
400         assert_eq!(s.updated, 0);
401     }
402 
403     #[test]
404     fn the_create_template_matches_op_s_login_shape() {
405         let e = RemoteEntry {
406             title: "T".into(),
407             username: "u".into(),
408             password: "p".into(),
409             url: "https://x.example".into(),
410             notes: "n".into(),
411             ..Default::default()
412         };
413         let t = create_template(&e);
414         assert_eq!(t["category"], "LOGIN");
415         assert_eq!(t["urls"][0]["primary"], true);
416         assert_eq!(t["urls"][0]["href"], "https://x.example");
417         let e2 = entry_from_json(&t);
418         assert_eq!((e2.title, e2.username, e2.password, e2.url, e2.notes), ("T".into(), "u".into(), "p".into(), "https://x.example".into(), "n".into()));
419         let no_url = create_template(&RemoteEntry::default());
420         assert!(no_url.get("urls").is_none(), "an empty url adds no urls key");
421     }
422 
423     #[test]
424     fn apply_entry_rewrites_synced_fields_and_keeps_the_rest() {
425         let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
426         v["fields"] = json!([
427             {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
428             {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "old"},
429             {"id": "custom", "type": "STRING", "label": "pin", "value": "1234", "section": {"id": "s1"}}
430         ]);
431         let e = RemoteEntry {
432             id: "abc".into(),
433             title: "Renamed".into(),
434             username: "someone".into(),
435             password: "new".into(),
436             url: "https://new.example.com".into(),
437             notes: "added".into(),
438             ..Default::default()
439         };
440         apply_entry(&mut v, &e);
441         assert_eq!(v["title"], "Renamed");
442         assert_eq!(v["urls"][1]["href"], "https://new.example.com", "the primary url is replaced in place");
443         assert_eq!(v["urls"][0]["href"], "https://old.example.com", "other urls survive");
444         let got = entry_from_json(&v);
445         assert_eq!(got.password, "new");
446         assert_eq!(got.notes, "added", "a missing purpose field is appended");
447         assert_eq!(v["fields"][2]["value"], "1234", "custom fields survive");
448         assert_eq!(v["tags"], json!([]), "unrelated keys survive");
449     }
450 
451     #[test]
452     fn rfc3339_parses_both_forms_op_prints_to_the_same_second() {
453         assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
454         assert_eq!(parse_rfc3339("2026-09-21T15:41:14Z"), Some(1790005274));
455         assert_eq!(parse_rfc3339("2026-09-21T15:41:14.5Z"), Some(1790005274));
456         // `op item edit` printed this for the item `op item list` showed as 2026-09-21T16:27:42Z.
457         assert_eq!(parse_rfc3339("2026-09-21T12:27:42.39627885-04:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
458         assert_eq!(parse_rfc3339("2026-09-21T18:27:42+02:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
459         assert_eq!(parse_rfc3339(""), None);
460         assert_eq!(parse_rfc3339("nope"), None);
461     }
462 
463     #[test]
464     fn the_canonical_form_round_trips() {
465         for t in [0i64, 951782400, 1790005274, 1790008062, 4102444799] {
466             assert_eq!(parse_rfc3339(&format_rfc3339(t)), Some(t), "{t}");
467         }
468         assert_eq!(format_rfc3339(1790005274), "2026-09-21T15:41:14Z");
469         assert_eq!(format_rfc3339(951782400), "2000-02-29T00:00:00Z");
470         let v: Value = json!({"updated_at": "2026-09-21T12:27:42.39627885-04:00"});
471         assert_eq!(updated_of(&v).1, "2026-09-21T16:27:42Z", "a get/edit reply stores as the list form");
472     }
473 
474     #[test]
475     fn a_dismissed_prompt_is_recognised() {
476         assert!(is_dismissed("op item list: authorization prompt dismissed, please try again"));
477         assert!(!is_dismissed("op item list: account is not signed in"));
478     }
479 }