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 }