Skip to main content

bwx/
pinentry_native.rs

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