Skip to main content

yui/
vault.rs

1//! Pluggable secret-vault backend used by `yui secret store` /
2//! `yui secret unlock` to ferry the X25519 identity across
3//! machines.
4//!
5//! yui doesn't authenticate against the vault itself — it shells
6//! out to the provider's official CLI (`bw` for Bitwarden, `op`
7//! for 1Password). Whatever auth that CLI supports (master
8//! password, biometric, passkey unlock via the web vault, SSO)
9//! gates the operation, and yui inherits it for free.
10//!
11//! Storage convention: the entire content of the X25519 identity
12//! file (header comments + the `AGE-SECRET-KEY-1…` line) lives in
13//! a Secure Note item under a user-chosen name. Picking notes
14//! (rather than the password field) keeps the multi-line content
15//! intact and doesn't pollute the vault's password-autofill UI.
16//!
17//! ## What yui *doesn't* try to do
18//!
19//! - Drive `bw login` / `op signin`. Those are interactive flows
20//!   the user runs once per machine; yui just calls the CLI on
21//!   the assumption it's already authenticated.
22//! - Manage vault TOTP / passkey enrolment. Those live in the
23//!   vault provider's own UI.
24//! - Encrypt the X25519 a second time. The vault's own at-rest
25//!   encryption is the trust boundary.
26
27use std::process::{Command, Stdio};
28
29use crate::config::{VaultConfig, VaultProvider};
30use crate::{Error, Result};
31
32/// Common interface for "fetch a Secure Note's content" and
33/// "store a Secure Note's content" — the only two operations
34/// `secret store` / `secret unlock` need.
35pub trait Vault {
36    /// Verify that the vault CLI is installed and authenticated
37    /// before we try to read or write. yui doesn't drive
38    /// `bw login` / `op signin` / `bw unlock` itself (the master
39    /// password / passkey / SSO factor should go to the
40    /// provider's CLI directly, not through yui's stdin), but it
41    /// can at least detect the unauthenticated / locked state up
42    /// front and emit an actionable hint instead of letting the
43    /// raw provider error propagate.
44    fn precheck(&self) -> Result<()>;
45
46    /// Read the notes field of `item`. Errors if the item is
47    /// missing or the CLI isn't installed. (Auth state is checked
48    /// separately via `precheck`.)
49    fn fetch(&self, item: &str) -> Result<Vec<u8>>;
50
51    /// Create or overwrite the Secure Note at `item` with
52    /// `content` in the notes field. `force = false` refuses to
53    /// clobber an existing item; `force = true` overwrites.
54    fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()>;
55
56    /// Human-readable provider name for log output.
57    fn provider_name(&self) -> &'static str;
58}
59
60/// Build a vault driver from the user's config.
61pub fn driver(cfg: &VaultConfig) -> Box<dyn Vault> {
62    match cfg.provider {
63        VaultProvider::Bitwarden => Box::new(BitwardenVault),
64        VaultProvider::OnePassword => Box::new(OnePasswordVault),
65    }
66}
67
68// ---------- Bitwarden ----------------------------------------------------
69
70struct BitwardenVault;
71
72impl Vault for BitwardenVault {
73    fn provider_name(&self) -> &'static str {
74        "Bitwarden"
75    }
76
77    fn precheck(&self) -> Result<()> {
78        let out = Command::new("bw").args(["status"]).output().map_err(|e| {
79            Error::Other(anyhow::anyhow!(
80                "invoking `bw status`: {e} — is the Bitwarden CLI installed?"
81            ))
82        })?;
83        if !out.status.success() {
84            let stderr = String::from_utf8_lossy(&out.stderr);
85            return Err(Error::Other(anyhow::anyhow!(
86                "bw status failed: {}",
87                stderr.trim()
88            )));
89        }
90        let v: serde_json::Value = serde_json::from_slice(&out.stdout)
91            .map_err(|e| Error::Other(anyhow::anyhow!("parse bw status output: {e}")))?;
92        match v.get("status").and_then(|s| s.as_str()) {
93            Some("unlocked") => Ok(()),
94            Some("locked") => Err(Error::Other(anyhow::anyhow!(
95                "Bitwarden vault is locked. Run `bw unlock` and follow \
96                 its instructions to export the BW_SESSION env var, then \
97                 retry. (BW vault unlock can use a passkey via the web \
98                 vault flow if you've set that up.)"
99            ))),
100            Some("unauthenticated") => Err(Error::Other(anyhow::anyhow!(
101                "Bitwarden CLI is not logged in. Run `bw login` (or \
102                 `bw login --apikey` for non-interactive SSO/API-key \
103                 use), then `bw unlock`, then retry."
104            ))),
105            other => Err(Error::Other(anyhow::anyhow!(
106                "unexpected `bw status` output: status={other:?}"
107            ))),
108        }
109    }
110
111    fn fetch(&self, item: &str) -> Result<Vec<u8>> {
112        // `bw get notes <item>` returns the notes field as plain
113        // text on stdout. `bw` will refuse with a non-zero exit
114        // when the item is missing or the vault is locked, which
115        // we surface verbatim so the user sees the bw error.
116        let output = Command::new("bw")
117            .args(["get", "notes", item])
118            .output()
119            .map_err(|e| Error::Other(anyhow::anyhow!(
120                "invoking `bw`: {e} — install Bitwarden CLI and run `bw login` + `bw unlock` once"
121            )))?;
122        if !output.status.success() {
123            let stderr = String::from_utf8_lossy(&output.stderr);
124            return Err(Error::Other(anyhow::anyhow!(
125                "bw get notes {item:?} failed: {}",
126                stderr.trim()
127            )));
128        }
129        Ok(output.stdout)
130    }
131
132    fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
133        let content_str = std::str::from_utf8(content)
134            .map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
135
136        // Check whether an item with that name already exists.
137        // `bw get item <name>` exits non-zero when missing.
138        let existing = Command::new("bw")
139            .args(["get", "item", item])
140            .output()
141            .map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
142
143        let item_json = serde_json::json!({
144            "type": 2,                     // 2 = Secure Note
145            "name": item,
146            "notes": content_str,
147            "secureNote": { "type": 0 },   // 0 = generic note
148        });
149        let payload = serde_json::to_vec(&item_json)
150            .map_err(|e| Error::Other(anyhow::anyhow!("serialise bw item JSON: {e}")))?;
151        let encoded = bw_encode(&payload)?;
152
153        if existing.status.success() {
154            if !force {
155                return Err(Error::Other(anyhow::anyhow!(
156                    "Bitwarden item {item:?} already exists; pass --force to overwrite"
157                )));
158            }
159            // Pull the existing item's id so we can `bw edit item <id>`.
160            let existing_value: serde_json::Value = serde_json::from_slice(&existing.stdout)
161                .map_err(|e| Error::Other(anyhow::anyhow!("parse bw get item output: {e}")))?;
162            let id = existing_value
163                .get("id")
164                .and_then(|v| v.as_str())
165                .ok_or_else(|| Error::Other(anyhow::anyhow!("bw item {item:?} has no id field")))?;
166            run_bw_with_stdin(&["edit", "item", id], encoded.as_bytes())?;
167        } else {
168            run_bw_with_stdin(&["create", "item"], encoded.as_bytes())?;
169        }
170        Ok(())
171    }
172}
173
174/// Base64-encode `payload` for `bw create item` / `bw edit item`,
175/// which both expect a base64'd JSON blob on stdin (the same
176/// shape `bw encode` produces).
177fn bw_encode(payload: &[u8]) -> Result<String> {
178    use base64::Engine as _;
179    Ok(base64::engine::general_purpose::STANDARD.encode(payload))
180}
181
182fn run_bw_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
183    use std::io::Write as _;
184    let mut child = Command::new("bw")
185        .args(args)
186        .stdin(Stdio::piped())
187        .stdout(Stdio::piped())
188        .stderr(Stdio::piped())
189        .spawn()
190        .map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
191    child
192        .stdin
193        .as_mut()
194        .ok_or_else(|| Error::Other(anyhow::anyhow!("bw stdin closed early")))?
195        .write_all(stdin_bytes)
196        .map_err(|e| Error::Other(anyhow::anyhow!("writing to bw stdin: {e}")))?;
197    let output = child
198        .wait_with_output()
199        .map_err(|e| Error::Other(anyhow::anyhow!("waiting on bw: {e}")))?;
200    if !output.status.success() {
201        let stderr = String::from_utf8_lossy(&output.stderr);
202        return Err(Error::Other(anyhow::anyhow!(
203            "bw {} failed: {}",
204            args.join(" "),
205            stderr.trim()
206        )));
207    }
208    Ok(())
209}
210
211// ---------- 1Password ----------------------------------------------------
212
213struct OnePasswordVault;
214
215impl Vault for OnePasswordVault {
216    fn provider_name(&self) -> &'static str {
217        "1Password"
218    }
219
220    fn precheck(&self) -> Result<()> {
221        // `op whoami` exits non-zero when the session is gone or
222        // the desktop-app integration isn't unlocked. Stderr from
223        // op already carries a decent message ("[ERROR] you are
224        // not currently signed in. ..."); we wrap it with a yui-
225        // shaped hint so the user doesn't have to wonder which
226        // command we just tried.
227        let out = Command::new("op").args(["whoami"]).output().map_err(|e| {
228            Error::Other(anyhow::anyhow!(
229                "invoking `op whoami`: {e} — is the 1Password CLI installed?"
230            ))
231        })?;
232        if out.status.success() {
233            return Ok(());
234        }
235        let stderr = String::from_utf8_lossy(&out.stderr);
236        Err(Error::Other(anyhow::anyhow!(
237            "1Password CLI is not signed in: {}. \
238             Run `op signin` (or unlock the 1Password desktop app to \
239             auto-share its session via the CLI integration), then retry.",
240            stderr.trim()
241        )))
242    }
243
244    fn fetch(&self, item: &str) -> Result<Vec<u8>> {
245        // `op item get <item> --field notesPlain` returns the
246        // notes field as plain text on stdout.
247        let output = Command::new("op")
248            .args(["item", "get", item, "--field", "notesPlain"])
249            .output()
250            .map_err(|e| {
251                Error::Other(anyhow::anyhow!(
252                    "invoking `op`: {e} — install 1Password CLI and run `op signin` once"
253                ))
254            })?;
255        if !output.status.success() {
256            let stderr = String::from_utf8_lossy(&output.stderr);
257            return Err(Error::Other(anyhow::anyhow!(
258                "op item get {item:?} --field notesPlain failed: {}",
259                stderr.trim()
260            )));
261        }
262        Ok(output.stdout)
263    }
264
265    fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
266        let content_str = std::str::from_utf8(content)
267            .map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
268
269        let existing = Command::new("op")
270            .args(["item", "get", item])
271            .output()
272            .map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
273
274        // Build a JSON template and pipe it via stdin instead of
275        // passing the secret as `notesPlain[text]=…` argv. The
276        // assignment-statement form is documented but exposes
277        // the secret to local `ps` / WMIC inspection while the
278        // op process is alive — JSON-via-stdin is 1Password's
279        // recommended secure flow. (PR #61 review by coderabbitai.)
280        let template = serde_json::json!({
281            "title": item,
282            "category": "SECURE_NOTE",
283            "fields": [
284                {
285                    "id": "notesPlain",
286                    "type": "STRING",
287                    "purpose": "NOTES",
288                    "label": "notesPlain",
289                    "value": content_str,
290                }
291            ],
292        });
293        let payload = serde_json::to_vec(&template)
294            .map_err(|e| Error::Other(anyhow::anyhow!("serialise op item template: {e}")))?;
295
296        if existing.status.success() {
297            if !force {
298                return Err(Error::Other(anyhow::anyhow!(
299                    "1Password item {item:?} already exists; pass --force to overwrite"
300                )));
301            }
302            run_op_with_stdin(&["item", "edit", item, "-"], &payload)?;
303        } else {
304            run_op_with_stdin(&["item", "create", "-"], &payload)?;
305        }
306        Ok(())
307    }
308}
309
310fn run_op_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
311    use std::io::Write as _;
312    let mut child = Command::new("op")
313        .args(args)
314        .stdin(Stdio::piped())
315        .stdout(Stdio::piped())
316        .stderr(Stdio::piped())
317        .spawn()
318        .map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
319    child
320        .stdin
321        .as_mut()
322        .ok_or_else(|| Error::Other(anyhow::anyhow!("op stdin closed early")))?
323        .write_all(stdin_bytes)
324        .map_err(|e| Error::Other(anyhow::anyhow!("writing to op stdin: {e}")))?;
325    let output = child
326        .wait_with_output()
327        .map_err(|e| Error::Other(anyhow::anyhow!("waiting on op: {e}")))?;
328    if !output.status.success() {
329        let stderr = String::from_utf8_lossy(&output.stderr);
330        return Err(Error::Other(anyhow::anyhow!(
331            "op {} failed: {}",
332            args.join(" "),
333            stderr.trim()
334        )));
335    }
336    Ok(())
337}