1use std::collections::{BTreeMap, HashMap};
21use std::io::Read;
22use std::path::PathBuf;
23use std::process::{Command, Stdio};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26
27use serde_json::Value;
28
29use super::{Driver, SecretMeta};
30use crate::error::{Error, Result};
31use crate::org::OrgId;
32
33pub const TOKEN_SECRET: &str = "onepassword-token";
35
36const OP_TIMEOUT: Duration = Duration::from_secs(30);
38
39pub const ITEM_TTL: Duration = Duration::from_secs(20);
43
44pub type TokenSource = Arc<dyn Fn(&OrgId) -> Result<Option<String>> + Send + Sync>;
46
47type ItemCache = HashMap<(String, String, String), (Instant, Arc<Value>)>;
49
50pub struct OnePasswordDriver {
51 op: PathBuf,
52 token: TokenSource,
53 items: Mutex<ItemCache>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Reference {
60 pub vault: String,
61 pub item: String,
62 pub field: String,
63}
64
65impl Reference {
66 pub fn parse(r: &str) -> Result<Reference> {
67 let r = r.strip_prefix("op://").unwrap_or(r);
68 let parts: Vec<&str> = r.split('/').collect();
69 if parts.len() < 3 || parts.len() > 4 || parts.iter().any(|p| p.trim().is_empty()) {
70 return Err(Error::invalid(format!(
71 "1Password reference {r:?}: expected vault/item/field (or vault/item/section/field)"
72 )));
73 }
74 Ok(Reference {
75 vault: parts[0].into(),
76 item: parts[1].into(),
77 field: parts[2..].join("/"),
78 })
79 }
80
81 fn uri(&self) -> String {
82 format!("op://{}/{}/{}", self.vault, self.item, self.field)
83 }
84}
85
86impl OnePasswordDriver {
87 pub fn new(token: TokenSource) -> OnePasswordDriver {
89 let op = std::env::var_os("ISB_OP_BIN")
90 .map(PathBuf::from)
91 .unwrap_or_else(|| PathBuf::from("op"));
92 OnePasswordDriver {
93 op,
94 token,
95 items: Mutex::new(HashMap::new()),
96 }
97 }
98
99 pub fn with_binary(mut self, op: impl Into<PathBuf>) -> Self {
100 self.op = op.into();
101 self
102 }
103
104 fn op(&self, org: &OrgId, args: &[&str]) -> Result<Vec<u8>> {
107 let token = (self.token)(org)?.ok_or_else(|| {
108 Error::invalid(format!(
109 "org {org} has no 1Password token: store its service-account token as the secret {TOKEN_SECRET:?} (isb secret create {TOKEN_SECRET} --org {org} -)"
110 ))
111 })?;
112 let mut cmd = Command::new(&self.op);
113 cmd.args(args)
114 .env_clear()
115 .env("OP_SERVICE_ACCOUNT_TOKEN", token)
116 .stdin(Stdio::null())
117 .stdout(Stdio::piped())
118 .stderr(Stdio::piped());
119 for k in ["PATH", "HOME", "XDG_CONFIG_HOME", "TMPDIR"] {
120 if let Some(v) = std::env::var_os(k) {
121 cmd.env(k, v);
122 }
123 }
124 let mut child = cmd.spawn().map_err(|e| {
125 Error::invalid(format!(
126 "cannot run {} for the onepassword driver: {e} (install the 1Password CLI, or set ISB_OP_BIN)",
127 self.op.display()
128 ))
129 })?;
130 let (mut out, mut err) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
131 let reader = std::thread::spawn(move || {
132 let mut b = Vec::new();
133 let _ = out.read_to_end(&mut b);
134 b
135 });
136 let err_reader = std::thread::spawn(move || {
137 let mut b = Vec::new();
138 let _ = err.read_to_end(&mut b);
139 b
140 });
141 let started = Instant::now();
142 let status = loop {
143 if let Some(s) = child.try_wait()? {
144 break s;
145 }
146 if started.elapsed() > OP_TIMEOUT {
147 let _ = child.kill();
148 let _ = child.wait();
149 return Err(Error::invalid(format!(
150 "op {} took longer than {OP_TIMEOUT:?}",
151 args.first().unwrap_or(&"")
152 )));
153 }
154 std::thread::sleep(Duration::from_millis(20));
155 };
156 let stdout = reader.join().unwrap_or_default();
157 let stderr = err_reader.join().unwrap_or_default();
158 if !status.success() {
159 let msg = String::from_utf8_lossy(&stderr);
160 let msg = msg.trim();
161 if msg.contains("isn't an item") || msg.contains("not found") || msg.contains("no item")
162 {
163 return Err(Error::NotFound(format!(
164 "1Password {}",
165 args.last().unwrap_or(&"")
166 )));
167 }
168 return Err(Error::invalid(format!(
169 "op {}: {msg}",
170 args.first().unwrap_or(&"")
171 )));
172 }
173 Ok(stdout)
174 }
175
176 fn item_key(org: &OrgId, r: &Reference) -> (String, String, String) {
177 (org.to_string(), r.vault.clone(), r.item.clone())
178 }
179
180 fn item(&self, org: &OrgId, r: &Reference) -> Result<Arc<Value>> {
182 let k = Self::item_key(org, r);
183 if let Some((at, v)) = self.items.lock().unwrap().get(&k) {
184 if at.elapsed() < ITEM_TTL {
185 return Ok(v.clone());
186 }
187 }
188 self.fetch_item(org, r)
189 }
190
191 fn fetch_item(&self, org: &OrgId, r: &Reference) -> Result<Arc<Value>> {
193 let out = self.op(
194 org,
195 &[
196 "item", "get", &r.item, "--vault", &r.vault, "--format", "json",
197 ],
198 )?;
199 let v: Value = serde_json::from_slice(&out)
200 .map_err(|e| Error::Protocol(format!("op item get: {e}")))?;
201 let v = Arc::new(v);
202 let mut items = self.items.lock().unwrap();
203 items.retain(|_, (at, _)| at.elapsed() < ITEM_TTL);
204 items.insert(Self::item_key(org, r), (Instant::now(), v.clone()));
205 Ok(v)
206 }
207
208 fn meta(&self, org: &OrgId, name: &str, item: &Value) -> SecretMeta {
209 let version = item["version"].as_u64().unwrap_or(0);
210 let ts = |k: &str| item[k].as_str().and_then(rfc3339_to_unix).unwrap_or(0);
211 SecretMeta {
212 org: org.clone(),
213 name: name.to_string(),
214 driver: "onepassword".into(),
215 version,
216 created_at: ts("created_at"),
217 updated_at: ts("updated_at"),
218 labels: BTreeMap::new(),
219 }
220 }
221}
222
223fn field_value(item: &Value, field: &str) -> Option<Vec<u8>> {
229 let (section, field) = match field.split_once('/') {
230 Some((s, f)) => (Some(s), f),
231 None => (None, field),
232 };
233 let named = |v: &Value, n: &str| v["id"].as_str() == Some(n) || v["label"].as_str() == Some(n);
234 let hits: Vec<&Value> = item["fields"]
235 .as_array()?
236 .iter()
237 .filter(|f| named(f, field))
238 .filter(|f| section.is_none_or(|s| named(&f["section"], s)))
239 .collect();
240 match hits.as_slice() {
241 [f] => f["value"].as_str().map(|v| v.as_bytes().to_vec()),
242 _ => None,
243 }
244}
245
246fn reference(name: &str) -> Result<Reference> {
249 if !name.contains('/') {
250 return Err(Error::NotFound(format!("1Password reference {name}")));
251 }
252 Reference::parse(name)
253}
254
255impl Driver for OnePasswordDriver {
256 fn name(&self) -> &str {
257 "onepassword"
258 }
259
260 fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
261 let r = reference(name)?;
262 let item = self.item(org, &r)?;
263 let version = item["version"].as_u64().unwrap_or(0);
264 let value = match field_value(&item, &r.field) {
265 Some(v) => v,
266 None => self.op(org, &["read", "--no-newline", &r.uri()])?,
267 };
268 Ok((value, version))
269 }
270
271 fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
274 let r = reference(name)?;
275 Ok(self.fetch_item(org, &r)?["version"].as_u64().unwrap_or(0))
276 }
277
278 fn versions(&self, org: &OrgId, names: &[&str]) -> Vec<Result<u64>> {
281 let mut round: HashMap<(String, String), std::result::Result<u64, String>> = HashMap::new();
282 names
283 .iter()
284 .map(|name| {
285 let r = reference(name)?;
286 let got = round
287 .entry((r.vault.clone(), r.item.clone()))
288 .or_insert_with(|| {
289 self.fetch_item(org, &r)
290 .map(|i| i["version"].as_u64().unwrap_or(0))
291 .map_err(|e| e.to_string())
292 });
293 got.clone().map_err(Error::invalid)
294 })
295 .collect()
296 }
297
298 fn refresh(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
300 let r = reference(name)?;
301 let item = self.fetch_item(org, &r)?;
302 Ok(self.meta(org, name, &item))
303 }
304
305 fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
306 let r = reference(name)?;
307 let item = self.item(org, &r)?;
308 Ok(self.meta(org, name, &item))
309 }
310
311 fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
314 Ok(Vec::new())
315 }
316}
317
318fn rfc3339_to_unix(s: &str) -> Option<u64> {
320 let (date, rest) = s.split_once('T')?;
321 let mut d = date.split('-').map(|x| x.parse::<i64>().ok());
322 let (y, m, day) = (d.next()??, d.next()??, d.next()??);
323 let time: String = rest.chars().take(8).collect();
324 let mut t = time.split(':').map(|x| x.parse::<i64>().ok());
325 let (hh, mm, ss) = (t.next()??, t.next()??, t.next()??);
326 let tail = &rest[8.min(rest.len())..];
327 let tail = tail.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
328 let offset = match tail {
329 "" | "Z" | "z" => 0,
330 o => {
331 let sign = if o.starts_with('-') { -1 } else { 1 };
332 let o = o.trim_start_matches(['+', '-']);
333 let (oh, om) = o.split_once(':').unwrap_or((o, "0"));
334 sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60)
335 }
336 };
337 let y2 = if m <= 2 { y - 1 } else { y };
339 let era = y2.div_euclid(400);
340 let yoe = y2 - era * 400;
341 let mp = (m + 9) % 12;
342 let doy = (153 * mp + 2) / 5 + day - 1;
343 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
344 let days = era * 146_097 + doe - 719_468;
345 let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset;
346 u64::try_from(secs).ok()
347}
348
349#[cfg(test)]
353pub(crate) mod fake {
354 use super::*;
355
356 pub(crate) struct FakeOp {
357 pub dir: tempfile::TempDir,
358 }
359
360 impl FakeOp {
361 pub(crate) fn new() -> FakeOp {
362 let dir = tempfile::tempdir().unwrap();
363 let d = dir.path().display();
364 std::fs::create_dir(dir.path().join("items")).unwrap();
365 let script = format!(
366 r#"#!/bin/sh
367echo "$*" >> {d}/calls
368case "$1 $2" in
369 "item get")
370 f="{d}/items/$5.$3"
371 [ -f "$f" ] || {{ echo "\"$3\" isn't an item" >&2; exit 1; }}
372 printf '{{"version": %s, "fields": [{{"id": "password", "label": "password", "value": "pw-%s-%s"}}, {{"id": "u1", "label": "user", "section": {{"id": "s1", "label": "login"}}, "value": "user-%s"}}]}}' "$(cat "$f")" "$3" "$(cat "$f")" "$3"
373 ;;
374 "read --no-newline") printf 'read:%s' "$3" ;;
375 *) exit 2 ;;
376esac
377"#
378 );
379 let op = dir.path().join("op");
380 std::fs::write(&op, script).unwrap();
381 std::fs::set_permissions(&op, std::os::unix::fs::PermissionsExt::from_mode(0o755))
382 .unwrap();
383 FakeOp { dir }
384 }
385
386 pub(crate) fn driver(&self) -> OnePasswordDriver {
387 let token: TokenSource = Arc::new(|_| Ok(Some("tok".to_string())));
388 OnePasswordDriver::new(token).with_binary(self.dir.path().join("op"))
389 }
390
391 pub(crate) fn set_version(&self, vault: &str, item: &str, v: u64) {
392 std::fs::write(
393 self.dir
394 .path()
395 .join("items")
396 .join(format!("{vault}.{item}")),
397 v.to_string(),
398 )
399 .unwrap();
400 }
401
402 pub(crate) fn calls(&self) -> Vec<String> {
404 std::fs::read_to_string(self.dir.path().join("calls"))
405 .unwrap_or_default()
406 .lines()
407 .map(String::from)
408 .collect()
409 }
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn references() {
419 let r = Reference::parse("k8s-ocai/smtp/password").unwrap();
420 assert_eq!(
421 (r.vault.as_str(), r.item.as_str(), r.field.as_str()),
422 ("k8s-ocai", "smtp", "password")
423 );
424 assert_eq!(r.uri(), "op://k8s-ocai/smtp/password");
425 assert_eq!(Reference::parse("op://v/i/s/f").unwrap().field, "s/f");
426 assert!(Reference::parse("v/i").is_err());
427 assert!(Reference::parse("v//f").is_err());
428 }
429
430 #[test]
431 fn timestamps() {
432 assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0));
433 assert_eq!(rfc3339_to_unix("2026-10-03T04:00:00Z"), Some(1_791_000_000));
434 assert_eq!(
435 rfc3339_to_unix("2026-10-03T06:00:00.123+02:00"),
436 Some(1_791_000_000)
437 );
438 }
439
440 #[test]
443 fn talks_to_op_with_the_orgs_token() {
444 let dir = tempfile::tempdir().unwrap();
445 let fake = dir.path().join("op");
446 std::fs::write(
447 &fake,
448 "#!/bin/sh\n\
449 [ \"$OP_SERVICE_ACCOUNT_TOKEN\" = tok-alpha ] || { echo 'bad token' >&2; exit 1; }\n\
450 case \"$1\" in\n\
451 read) printf 's3cret' ;;\n\
452 item) printf '{\"version\": 7, \"updated_at\": \"2026-10-03T04:00:00Z\"}' ;;\n\
453 *) exit 2 ;;\n\
454 esac\n",
455 )
456 .unwrap();
457 std::fs::set_permissions(&fake, std::os::unix::fs::PermissionsExt::from_mode(0o755))
458 .unwrap();
459 let alpha = OrgId::new("alpha").unwrap();
460 let token: TokenSource =
461 Arc::new(|o: &OrgId| Ok((o.as_str() == "alpha").then(|| "tok-alpha".to_string())));
462 let d = OnePasswordDriver::new(token).with_binary(&fake);
463 assert_eq!(d.get(&alpha, "v/i/f").unwrap(), (b"s3cret".to_vec(), 7));
464 assert_eq!(d.version(&alpha, "v/i/f").unwrap(), 7);
465 assert_eq!(
466 d.inspect(&alpha, "v/i/f").unwrap().updated_at,
467 1_791_000_000
468 );
469 assert!(d.list(&alpha).unwrap().is_empty());
470 let e = d
471 .get(&OrgId::new("beta").unwrap(), "v/i/f")
472 .unwrap_err()
473 .to_string();
474 assert!(e.contains("no 1Password token"), "{e}");
475 assert!(d.set(&alpha, "v/i/f", b"x").is_err());
476 }
477
478 #[test]
481 fn a_polling_round_asks_once_per_item() {
482 let op = fake::FakeOp::new();
483 for i in 0..10 {
484 op.set_version("v", &format!("item{i}"), 3);
485 }
486 let d = op.driver();
487 let org = OrgId::new("alpha").unwrap();
488 let refs: Vec<String> = (0..150)
489 .map(|n| match n % 3 {
490 0 => format!("v/item{}/password", n % 10),
491 1 => format!("v/item{}/login/user", n % 10),
492 _ => format!("v/item{}/field{n}", n % 10),
493 })
494 .collect();
495 let names: Vec<&str> = refs.iter().map(String::as_str).collect();
496 let got = d.versions(&org, &names);
497 assert!(got.iter().all(|v| *v.as_ref().unwrap() == 3));
498 assert_eq!(op.calls().len(), 10, "{:?}", op.calls());
499 assert!(op.calls().iter().all(|c| c.starts_with("item get")));
500
501 op.set_version("v", "item4", 4);
503 let got = d.versions(&org, &names);
504 assert_eq!(op.calls().len(), 20);
505 for (r, v) in refs.iter().zip(&got) {
506 let want = if r.starts_with("v/item4/") { 4 } else { 3 };
507 assert_eq!(*v.as_ref().unwrap(), want, "{r}");
508 }
509
510 for _ in 0..3 {
513 assert_eq!(
514 d.get(&org, "v/item4/password").unwrap(),
515 (b"pw-item4-4".to_vec(), 4)
516 );
517 assert_eq!(
518 d.get(&org, "v/item4/login/user").unwrap(),
519 (b"user-item4".to_vec(), 4)
520 );
521 }
522 assert_eq!(op.calls().len(), 20, "{:?}", op.calls());
523 assert_eq!(
525 d.get(&org, "v/item4/field9").unwrap(),
526 (b"read:op://v/item4/field9".to_vec(), 4)
527 );
528 assert_eq!(op.calls().len(), 21);
529 assert_eq!(d.refresh(&org, "v/item4/password").unwrap().version, 4);
531 assert_eq!(op.calls().len(), 22);
532 let got = d.versions(&org, &["v/ghost/a", "v/ghost/b", "v/item1/password"]);
534 assert!(got[0].is_err() && got[1].is_err());
535 assert_eq!(*got[2].as_ref().unwrap(), 3);
536 assert_eq!(op.calls().len(), 24);
537 let beta = OrgId::new("beta").unwrap();
539 d.get(&beta, "v/item4/password").unwrap();
540 assert_eq!(op.calls().len(), 25);
541 }
542
543 #[test]
546 fn the_facade_polls_mixed_names_in_one_round() {
547 let op = fake::FakeOp::new();
548 op.set_version("v", "a", 5);
549 op.set_version("v", "b", 6);
550 let dir = tempfile::tempdir().unwrap();
551 let k = crate::secrets::Keyring::new(age::x25519::Identity::generate(), vec![]);
552 let s =
553 crate::secrets::Secrets::new(crate::secrets::LocalDriver::new(dir.path(), Arc::new(k)))
554 .with_driver(Arc::new(op.driver()))
555 .unwrap();
556 let org = OrgId::default_org();
557 s.create(&org, "plain", None, b"x", &BTreeMap::new())
558 .unwrap();
559 let got = s.versions(
560 &org,
561 &[
562 "v/a/password",
563 "plain",
564 "v/a/login/user",
565 "v/b/password",
566 "v/ghost/x",
567 "missing",
568 ],
569 );
570 let ok: Vec<Option<u64>> = got.iter().map(|r| r.as_ref().ok().copied()).collect();
571 assert_eq!(ok, [Some(5), Some(1), Some(5), Some(6), None, None]);
572 assert_eq!(op.calls().len(), 3, "{:?}", op.calls());
573 }
574
575 #[test]
576 fn field_values_from_the_item() {
577 let item: Value = serde_json::json!({"fields": [
578 {"id": "password", "label": "password", "value": "pw"},
579 {"id": "x1", "label": "user", "section": {"id": "s1", "label": "login"}, "value": "u"},
580 {"id": "x2", "label": "user", "section": {"id": "s2", "label": "admin"}, "value": "a"},
581 {"id": "doc", "label": "doc"},
582 ]});
583 assert_eq!(field_value(&item, "password"), Some(b"pw".to_vec()));
584 assert_eq!(field_value(&item, "login/user"), Some(b"u".to_vec()));
585 assert_eq!(field_value(&item, "s2/x2"), Some(b"a".to_vec()));
586 assert_eq!(field_value(&item, "user"), None);
588 assert_eq!(field_value(&item, "doc"), None);
589 assert_eq!(field_value(&item, "nope"), None);
590 }
591}