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
13use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::PathBuf;
16use std::process::{Command, Stdio};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use serde_json::Value;
21
22use super::{Driver, SecretMeta};
23use crate::error::{Error, Result};
24use crate::org::OrgId;
25
26/// The org's `local` secret holding its 1Password service-account token.
27pub const TOKEN_SECRET: &str = "onepassword-token";
28
29/// How long one `op` call may take.
30const OP_TIMEOUT: Duration = Duration::from_secs(30);
31
32/// Reads an org's token: `Ok(None)` when the org has none.
33pub type TokenSource = Arc<dyn Fn(&OrgId) -> Result<Option<String>> + Send + Sync>;
34
35pub struct OnePasswordDriver {
36    op: PathBuf,
37    token: TokenSource,
38}
39
40/// A parsed `vault/item/[section/]field` reference.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Reference {
43    pub vault: String,
44    pub item: String,
45    pub field: String,
46}
47
48impl Reference {
49    pub fn parse(r: &str) -> Result<Reference> {
50        let r = r.strip_prefix("op://").unwrap_or(r);
51        let parts: Vec<&str> = r.split('/').collect();
52        if parts.len() < 3 || parts.len() > 4 || parts.iter().any(|p| p.trim().is_empty()) {
53            return Err(Error::invalid(format!(
54                "1Password reference {r:?}: expected vault/item/field (or vault/item/section/field)"
55            )));
56        }
57        Ok(Reference {
58            vault: parts[0].into(),
59            item: parts[1].into(),
60            field: parts[2..].join("/"),
61        })
62    }
63
64    fn uri(&self) -> String {
65        format!("op://{}/{}/{}", self.vault, self.item, self.field)
66    }
67}
68
69impl OnePasswordDriver {
70    /// `op` from `$ISB_OP_BIN`, else the first `op` on `$PATH`.
71    pub fn new(token: TokenSource) -> OnePasswordDriver {
72        let op = std::env::var_os("ISB_OP_BIN")
73            .map(PathBuf::from)
74            .unwrap_or_else(|| PathBuf::from("op"));
75        OnePasswordDriver { op, token }
76    }
77
78    pub fn with_binary(mut self, op: impl Into<PathBuf>) -> Self {
79        self.op = op.into();
80        self
81    }
82
83    /// Run `op` with the org's token and nothing else from our environment
84    /// but what it needs to find its config and binaries.
85    fn op(&self, org: &OrgId, args: &[&str]) -> Result<Vec<u8>> {
86        let token = (self.token)(org)?.ok_or_else(|| {
87            Error::invalid(format!(
88                "org {org} has no 1Password token: store its service-account token as the secret {TOKEN_SECRET:?} (isb secret create {TOKEN_SECRET} --org {org} -)"
89            ))
90        })?;
91        let mut cmd = Command::new(&self.op);
92        cmd.args(args)
93            .env_clear()
94            .env("OP_SERVICE_ACCOUNT_TOKEN", token)
95            .stdin(Stdio::null())
96            .stdout(Stdio::piped())
97            .stderr(Stdio::piped());
98        for k in ["PATH", "HOME", "XDG_CONFIG_HOME", "TMPDIR"] {
99            if let Some(v) = std::env::var_os(k) {
100                cmd.env(k, v);
101            }
102        }
103        let mut child = cmd.spawn().map_err(|e| {
104            Error::invalid(format!(
105                "cannot run {} for the onepassword driver: {e} (install the 1Password CLI, or set ISB_OP_BIN)",
106                self.op.display()
107            ))
108        })?;
109        let (mut out, mut err) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
110        let reader = std::thread::spawn(move || {
111            let mut b = Vec::new();
112            let _ = out.read_to_end(&mut b);
113            b
114        });
115        let err_reader = std::thread::spawn(move || {
116            let mut b = Vec::new();
117            let _ = err.read_to_end(&mut b);
118            b
119        });
120        let started = Instant::now();
121        let status = loop {
122            if let Some(s) = child.try_wait()? {
123                break s;
124            }
125            if started.elapsed() > OP_TIMEOUT {
126                let _ = child.kill();
127                let _ = child.wait();
128                return Err(Error::invalid(format!(
129                    "op {} took longer than {OP_TIMEOUT:?}",
130                    args.first().unwrap_or(&"")
131                )));
132            }
133            std::thread::sleep(Duration::from_millis(20));
134        };
135        let stdout = reader.join().unwrap_or_default();
136        let stderr = err_reader.join().unwrap_or_default();
137        if !status.success() {
138            let msg = String::from_utf8_lossy(&stderr);
139            let msg = msg.trim();
140            if msg.contains("isn't an item") || msg.contains("not found") || msg.contains("no item")
141            {
142                return Err(Error::NotFound(format!(
143                    "1Password {}",
144                    args.last().unwrap_or(&"")
145                )));
146            }
147            return Err(Error::invalid(format!(
148                "op {}: {msg}",
149                args.first().unwrap_or(&"")
150            )));
151        }
152        Ok(stdout)
153    }
154
155    fn item(&self, org: &OrgId, r: &Reference) -> Result<Value> {
156        let out = self.op(
157            org,
158            &[
159                "item", "get", &r.item, "--vault", &r.vault, "--format", "json",
160            ],
161        )?;
162        serde_json::from_slice(&out).map_err(|e| Error::Protocol(format!("op item get: {e}")))
163    }
164
165    fn meta(&self, org: &OrgId, name: &str, item: &Value) -> SecretMeta {
166        let version = item["version"].as_u64().unwrap_or(0);
167        let ts = |k: &str| item[k].as_str().and_then(rfc3339_to_unix).unwrap_or(0);
168        SecretMeta {
169            org: org.clone(),
170            name: name.to_string(),
171            driver: "onepassword".into(),
172            version,
173            created_at: ts("created_at"),
174            updated_at: ts("updated_at"),
175            labels: BTreeMap::new(),
176        }
177    }
178}
179
180/// A name that is not a reference at all is simply not this driver's (the
181/// facade asks every driver); a malformed reference is an error.
182fn reference(name: &str) -> Result<Reference> {
183    if !name.contains('/') {
184        return Err(Error::NotFound(format!("1Password reference {name}")));
185    }
186    Reference::parse(name)
187}
188
189impl Driver for OnePasswordDriver {
190    fn name(&self) -> &str {
191        "onepassword"
192    }
193
194    fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
195        let r = reference(name)?;
196        let value = self.op(org, &["read", "--no-newline", &r.uri()])?;
197        let version = self.item(org, &r)?["version"].as_u64().unwrap_or(0);
198        Ok((value, version))
199    }
200
201    fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
202        let r = reference(name)?;
203        Ok(self.item(org, &r)?["version"].as_u64().unwrap_or(0))
204    }
205
206    fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
207        let r = reference(name)?;
208        let item = self.item(org, &r)?;
209        Ok(self.meta(org, name, &item))
210    }
211
212    /// A vault is not this org's to enumerate: references are listed where
213    /// they are used (stacks), not here.
214    fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
215        Ok(Vec::new())
216    }
217}
218
219/// `2026-10-03T05:12:11Z` (or with an offset) as unix seconds.
220fn rfc3339_to_unix(s: &str) -> Option<u64> {
221    let (date, rest) = s.split_once('T')?;
222    let mut d = date.split('-').map(|x| x.parse::<i64>().ok());
223    let (y, m, day) = (d.next()??, d.next()??, d.next()??);
224    let time: String = rest.chars().take(8).collect();
225    let mut t = time.split(':').map(|x| x.parse::<i64>().ok());
226    let (hh, mm, ss) = (t.next()??, t.next()??, t.next()??);
227    let tail = &rest[8.min(rest.len())..];
228    let tail = tail.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
229    let offset = match tail {
230        "" | "Z" | "z" => 0,
231        o => {
232            let sign = if o.starts_with('-') { -1 } else { 1 };
233            let o = o.trim_start_matches(['+', '-']);
234            let (oh, om) = o.split_once(':').unwrap_or((o, "0"));
235            sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60)
236        }
237    };
238    // Days from civil (Howard Hinnant).
239    let y2 = if m <= 2 { y - 1 } else { y };
240    let era = y2.div_euclid(400);
241    let yoe = y2 - era * 400;
242    let mp = (m + 9) % 12;
243    let doy = (153 * mp + 2) / 5 + day - 1;
244    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
245    let days = era * 146_097 + doe - 719_468;
246    let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset;
247    u64::try_from(secs).ok()
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn references() {
256        let r = Reference::parse("k8s-ocai/smtp/password").unwrap();
257        assert_eq!(
258            (r.vault.as_str(), r.item.as_str(), r.field.as_str()),
259            ("k8s-ocai", "smtp", "password")
260        );
261        assert_eq!(r.uri(), "op://k8s-ocai/smtp/password");
262        assert_eq!(Reference::parse("op://v/i/s/f").unwrap().field, "s/f");
263        assert!(Reference::parse("v/i").is_err());
264        assert!(Reference::parse("v//f").is_err());
265    }
266
267    #[test]
268    fn timestamps() {
269        assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0));
270        assert_eq!(rfc3339_to_unix("2026-10-03T04:00:00Z"), Some(1_791_000_000));
271        assert_eq!(
272            rfc3339_to_unix("2026-10-03T06:00:00.123+02:00"),
273            Some(1_791_000_000)
274        );
275    }
276
277    /// A fake `op` that checks it got the org's token from the environment
278    /// (and nothing from argv), and answers `read` and `item get`.
279    #[test]
280    fn talks_to_op_with_the_orgs_token() {
281        let dir = tempfile::tempdir().unwrap();
282        let fake = dir.path().join("op");
283        std::fs::write(
284            &fake,
285            "#!/bin/sh\n\
286             [ \"$OP_SERVICE_ACCOUNT_TOKEN\" = tok-alpha ] || { echo 'bad token' >&2; exit 1; }\n\
287             case \"$1\" in\n\
288               read) printf 's3cret' ;;\n\
289               item) printf '{\"version\": 7, \"updated_at\": \"2026-10-03T04:00:00Z\"}' ;;\n\
290               *) exit 2 ;;\n\
291             esac\n",
292        )
293        .unwrap();
294        std::fs::set_permissions(&fake, std::os::unix::fs::PermissionsExt::from_mode(0o755))
295            .unwrap();
296        let alpha = OrgId::new("alpha").unwrap();
297        let token: TokenSource =
298            Arc::new(|o: &OrgId| Ok((o.as_str() == "alpha").then(|| "tok-alpha".to_string())));
299        let d = OnePasswordDriver::new(token).with_binary(&fake);
300        assert_eq!(d.get(&alpha, "v/i/f").unwrap(), (b"s3cret".to_vec(), 7));
301        assert_eq!(d.version(&alpha, "v/i/f").unwrap(), 7);
302        assert_eq!(
303            d.inspect(&alpha, "v/i/f").unwrap().updated_at,
304            1_791_000_000
305        );
306        assert!(d.list(&alpha).unwrap().is_empty());
307        let e = d
308            .get(&OrgId::new("beta").unwrap(), "v/i/f")
309            .unwrap_err()
310            .to_string();
311        assert!(e.contains("no 1Password token"), "{e}");
312        assert!(d.set(&alpha, "v/i/f", b"x").is_err());
313    }
314}