Skip to main content

isb_core/secrets/
onepassword.rs

1//! The `onepassword` driver: secrets that live in 1Password, read with the
2//! `op` CLI and a service-account token that belongs to the org.
3//!
4//! A reference is `vault/item/field` (or `vault/item/section/field`), the
5//! `op://` path without its scheme; use the item's ID when its title has a
6//! `/` in it. The org's token is its `local` secret
7//! [`TOKEN_SECRET`]; it reaches `op` through the environment of that one
8//! child, never argv, and no other org's token is ever used for it. The
9//! driver is read-only: values are managed in 1Password. A secret's version
10//! is the 1Password item's version, which moves on every edit, so polling
11//! notices rotations.
12//!
13//! 1Password limits reads per account per day, so polling is grouped: every
14//! field of an item shares the item's version, so one `op item get` answers
15//! all the references into one item in a round ([`Driver::versions`]). The
16//! item it returns (fields and values included) is kept for [`ITEM_TTL`], so
17//! the reads that follow a version bump take their values from it instead
18//! of asking again per reference and per replica.
19
20use 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
33/// The org's `local` secret holding its 1Password service-account token.
34pub const TOKEN_SECRET: &str = "onepassword-token";
35
36/// How long one `op` call may take.
37const OP_TIMEOUT: Duration = Duration::from_secs(30);
38
39/// How long an item read from 1Password answers again: long enough to cover
40/// one polling round and the deliveries it sets off, short enough that a
41/// forced refresh or the next round always asks 1Password.
42pub const ITEM_TTL: Duration = Duration::from_secs(20);
43
44/// Reads an org's token: `Ok(None)` when the org has none.
45pub type TokenSource = Arc<dyn Fn(&OrgId) -> Result<Option<String>> + Send + Sync>;
46
47/// Items read lately, by (org, vault, item): when, and the item.
48type ItemCache = HashMap<(String, String, String), (Instant, Arc<Value>)>;
49
50pub struct OnePasswordDriver {
51    op: PathBuf,
52    token: TokenSource,
53    /// Items read lately, by (org, vault, item): when, and the item.
54    items: Mutex<ItemCache>,
55}
56
57/// A parsed `vault/item/[section/]field` reference.
58#[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    /// `op` from `$ISB_OP_BIN`, else the first `op` on `$PATH`.
88    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    /// Run `op` with the org's token and nothing else from our environment
105    /// but what it needs to find its config and binaries.
106    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    /// The item, from 1Password unless it was read within [`ITEM_TTL`].
181    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    /// The item, always from 1Password; remembered for [`ITEM_TTL`].
192    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
223/// A field's value in an item as `op item get --format json` prints it:
224/// `field` is a field's id or label, `section/field` one in a section (by
225/// its id or label). `None` when it is not there exactly once, or has no
226/// value (a file, an OTP), so the caller asks `op read`, which knows every
227/// form.
228fn 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
246/// A name that is not a reference at all is simply not this driver's (the
247/// facade asks every driver); a malformed reference is an error.
248fn 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    /// Polling asks 1Password every time; the answer then serves the reads
272    /// a new version sets off.
273    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    /// One `op item get` per (vault, item), whatever the number of fields
279    /// referenced in it.
280    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    /// Forget what was read lately, so the next look asks 1Password.
299    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    /// A vault is not this org's to enumerate: references are listed where
312    /// they are used (stacks), not here.
313    fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
314        Ok(Vec::new())
315    }
316}
317
318/// `2026-10-03T05:12:11Z` (or with an offset) as unix seconds.
319fn 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    // Days from civil (Howard Hinnant).
338    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/// A fake `op` that logs every call and serves items from a directory:
350/// `<dir>/items/<vault>.<item>` holds the item's version (a missing file is
351/// an unknown item). Each item has fields `password` and `login/user`.
352#[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        /// Every call so far, one `op` argv per line.
403        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    /// A fake `op` that checks it got the org's token from the environment
441    /// (and nothing from argv), and answers `read` and `item get`.
442    #[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    /// 150 references into 10 items, polled in one round: 10 `op item get`,
479    /// not 150. The values a bump then needs come from those answers.
480    #[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        // Item 4 moves: the next round still asks once per item, and sees it.
502        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        // Reading the moved references right after (what a roll does, per
511        // replica) costs nothing more: the round's answer holds the fields.
512        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        // A field the item's JSON does not carry is read with `op read`.
524        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        // A forced refresh always asks.
530        assert_eq!(d.refresh(&org, "v/item4/password").unwrap().version, 4);
531        assert_eq!(op.calls().len(), 22);
532        // An unknown item fails its own references only, once.
533        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        // Orgs never share an answer.
538        let beta = OrgId::new("beta").unwrap();
539        d.get(&beta, "v/item4/password").unwrap();
540        assert_eq!(op.calls().len(), 25);
541    }
542
543    /// The facade finds each name's driver: store names in `local`,
544    /// references in 1Password, all of them in one `op` call per item.
545    #[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        // Ambiguous, valueless or missing: left to `op read`.
587        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}