Skip to main content

bwx/
pinentry_native.rs

1//! Native macOS secure-text prompt for the master password and other
2//! short inputs (2FA codes, etc.).
3//!
4//! Shells out to `/usr/bin/osascript` with `display dialog` to render
5//! the modern Aqua system dialog (proper Apple-native buttons, shadow,
6//! rounded corners, automatic dark-mode theming). Unlike pinentry, it
7//! doesn't need a TTY or X11/DBus session; the dialog is rendered by
8//! `WindowServer` and will appear even for daemonized callers (GUI
9//! git signing, ssh-agent from a Finder-launched IDE).
10//!
11//! On non-macOS builds this module exposes the same function
12//! signature but returns an error, so callers can fall back to
13//! pinentry without cfg-guarding every call site.
14#![allow(clippy::doc_markdown)]
15
16use crate::locked;
17use crate::prelude::Error;
18
19/// Whether the dialog should mask typed characters. `Secret` → one-
20/// shot password dialog (bullets); `Visible` → plain text entry for
21/// 2FA codes / confirmation numbers.
22#[derive(Copy, Clone, Debug)]
23pub enum InputKind {
24    Secret,
25    Visible,
26}
27
28/// Blocks the calling thread until the user dismisses the dialog.
29/// Callers should wrap in `tokio::task::spawn_blocking` to avoid
30/// stalling the tokio runtime.
31pub fn prompt(
32    title: &str,
33    message: &str,
34    button: &str,
35    kind: InputKind,
36) -> Result<locked::Password, Error> {
37    #[cfg(target_os = "macos")]
38    {
39        imp::prompt(title, message, button, kind)
40    }
41    #[cfg(not(target_os = "macos"))]
42    {
43        let _ = (title, message, button, kind);
44        Err(Error::NativePromptUnsupported)
45    }
46}
47
48/// Back-compat shortcut for the original master-password call site.
49pub fn prompt_master_password(
50    title: &str,
51    message: &str,
52) -> Result<locked::Password, Error> {
53    prompt(title, message, "Unlock", InputKind::Secret)
54}
55
56#[cfg(target_os = "macos")]
57mod imp {
58    use std::process::Command;
59
60    use zeroize::Zeroize as _;
61
62    use super::{locked, Error, InputKind};
63
64    /// AppleScript double-quoted-string escape: backslash + double
65    /// quote. We never interpolate user-attacker-controlled strings
66    /// here, but harden anyway because the `title` and `message`
67    /// arguments are composed from profile names / error messages.
68    fn escape(s: &str) -> String {
69        let mut out = String::with_capacity(s.len() + 2);
70        out.push('"');
71        for ch in s.chars() {
72            match ch {
73                '\\' | '"' => {
74                    out.push('\\');
75                    out.push(ch);
76                }
77                _ => out.push(ch),
78            }
79        }
80        out.push('"');
81        out
82    }
83
84    const MARKER: &str = ", text returned:";
85
86    pub fn prompt(
87        title: &str,
88        message: &str,
89        button: &str,
90        kind: InputKind,
91    ) -> Result<locked::Password, Error> {
92        let hidden = match kind {
93            InputKind::Secret => "with hidden answer",
94            InputKind::Visible => "",
95        };
96        let script = format!(
97            "display dialog {msg} with title {title} \
98             default answer \"\" {hidden} \
99             buttons {{\"Cancel\", {btn}}} default button {btn} \
100             with icon caution",
101            msg = escape(message),
102            title = escape(title),
103            btn = escape(button),
104        );
105
106        let mut output = Command::new("/usr/bin/osascript")
107            .arg("-e")
108            .arg(&script)
109            .output()
110            .map_err(|e| Error::NativePromptFailed {
111                code: e.raw_os_error().unwrap_or(-1),
112                stage: "osascript spawn",
113            })?;
114
115        // Ensure the stdout buffer — which contains the typed password on
116        // the success path — is zeroed before `output` drops, regardless of
117        // which branch we leave by.
118        let result = extract_password(&output);
119        output.stdout.zeroize();
120        output.stderr.zeroize();
121        result
122    }
123
124    fn extract_password(
125        output: &std::process::Output,
126    ) -> Result<locked::Password, Error> {
127        if !output.status.success() {
128            let stderr = String::from_utf8_lossy(&output.stderr);
129            if stderr.contains("User canceled") || stderr.contains("-128") {
130                return Err(Error::PinentryCancelled);
131            }
132            return Err(Error::NativePromptFailed {
133                code: output.status.code().unwrap_or(-1),
134                stage: "osascript exit",
135            });
136        }
137
138        // osascript writes one line of the form
139        //   "button returned:Unlock, text returned:<value>\n"
140        // to stdout. Find the text-returned marker and take everything
141        // after it (stripping the trailing newline).
142        let Ok(stdout) = std::str::from_utf8(&output.stdout) else {
143            return Err(Error::NativePromptFailed {
144                code: 0,
145                stage: "osascript stdout utf8",
146            });
147        };
148        let value_str = stdout
149            .find(MARKER)
150            .map(|idx| stdout[idx + MARKER.len()..].trim_end_matches('\n'))
151            .ok_or(Error::NativePromptFailed {
152                code: 0,
153                stage: "osascript stdout parse",
154            })?;
155
156        let mut buf = locked::Vec::new();
157        buf.extend(value_str.as_bytes().iter().copied());
158        Ok(locked::Password::new(buf))
159    }
160}