envseal 0.3.11

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! macOS GUI dialog backend — drives `osascript` (built-in,
//! verified root-owned at `/usr/bin/osascript`) to render the
//! passphrase, secret-value, approval, TOTP, and preexec prompts via
//! the `AppleScript` `display dialog` command.

#![cfg(target_os = "macos")]

use super::Approval;
use crate::error::Error;
use crate::guard;
use std::process::Command;
use zeroize::Zeroizing;

/// Escape a string for safe interpolation inside an `AppleScript`
/// double-quoted literal. `AppleScript` only requires backslash and
/// double-quote to be escaped inside double-quoted strings — there is
/// no other interpolation we need to defend against.
pub(crate) fn escape_osa(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', " ")
        .replace('\r', " ")
}

pub(crate) fn macos_passphrase(
    is_new: bool,
    prev_error: Option<&str>,
) -> Result<Zeroizing<String>, Error> {
    if is_new {
        return macos_passphrase_create(prev_error);
    }
    let base_text = match prev_error {
        Some(err) => format!("{}\n\nEnter vault passphrase:", escape_osa(err)),
        None => "Enter vault passphrase:".to_string(),
    };
    let value = macos_password_prompt(&base_text)?;
    Ok(value)
}

/// Create flow on macOS. First-choice path: a single NSAlert with
/// two `NSSecureTextField`s rendered side-by-side, driven via JXA
/// (`osascript -l JavaScript`) into the Cocoa runtime. Matches the
/// Windows two-field experience without bringing in a Cocoa-linked
/// helper binary.
///
/// Fallback path: two sequential `display dialog` prompts with
/// match-or-retry, used if the JXA bridge fails (older macOS, JXA
/// disabled by MDM, ObjC bridge errors). The fallback path is what
/// shipped in v0.2.0 — guaranteed-correct, slightly worse UX.
fn macos_passphrase_create(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
    if let Ok(pass) = macos_passphrase_create_jxa(prev_error) {
        return Ok(pass);
    }
    macos_passphrase_create_two_prompt(prev_error)
}

/// Single-dialog create flow via JXA + NSAlert. The dialog has two
/// NSSecureTextField rows; the OK button only returns when both
/// match and meet the length requirement (validation happens in
/// JavaScript before the script writes to stdout, so a mismatch
/// reopens the dialog rather than producing a silent typo).
///
/// Output contract: on success, prints the confirmed passphrase to
/// stdout, no separator (only one value emitted). On cancel: empty
/// stdout, exit 0. On any internal error (ObjC bridge, framework
/// import): non-zero exit, falls back to the two-prompt path.
fn macos_passphrase_create_jxa(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
    let prefix = prev_error.map(escape_osa).unwrap_or_default();
    // Build the JS as a single literal — no string interpolation
    // beyond the prev_error prefix, so injection-style attacks via
    // `prev_error` content can't escape the JS string. `escape_osa`
    // already neutralizes backslashes and quotes for AppleScript;
    // those same rules cover JXA double-quoted strings.
    let js = format!(
        r#"
ObjC.import('Cocoa');
ObjC.import('Foundation');
function run() {{
    const PREV_ERR = "{prefix}";
    while (true) {{
        const alert = $.NSAlert.alloc.init;
        alert.messageText = 'envseal — Create Vault Passphrase';
        alert.informativeText = (PREV_ERR.length > 0 ? PREV_ERR + '\n\n' : '') +
            'Enter the same passphrase twice. Min 8 chars.';
        alert.addButtonWithTitle('Create');
        alert.addButtonWithTitle('Cancel');
        const stack = $.NSStackView.alloc.initWithFrame($.NSMakeRect(0, 0, 280, 60));
        stack.orientation = 1; // vertical
        stack.spacing = 8;
        const tf1 = $.NSSecureTextField.alloc.initWithFrame($.NSMakeRect(0, 0, 280, 24));
        const tf2 = $.NSSecureTextField.alloc.initWithFrame($.NSMakeRect(0, 0, 280, 24));
        tf1.placeholderString = 'Passphrase';
        tf2.placeholderString = 'Confirm';
        stack.addArrangedSubview(tf1);
        stack.addArrangedSubview(tf2);
        alert.accessoryView = stack;
        alert.window.initialFirstResponder = tf1;
        const r = alert.runModal;
        if (r !== 1000) {{ return ''; }}  // 1000 = first button
        const a = ObjC.unwrap(tf1.stringValue);
        const b = ObjC.unwrap(tf2.stringValue);
        if (a.length < 8) {{
            $.NSAlert.alloc.init.messageText = 'Too short';
            const w = $.NSAlert.alloc.init;
            w.messageText = 'Passphrase too short';
            w.informativeText = 'Min 8 characters required.';
            w.addButtonWithTitle('OK');
            w.runModal;
            continue;
        }}
        if (a !== b) {{
            const w = $.NSAlert.alloc.init;
            w.messageText = 'Passphrases did not match';
            w.informativeText = 'Try again.';
            w.addButtonWithTitle('OK');
            w.runModal;
            continue;
        }}
        return a;
    }}
}}
"#
    );
    let binary_path = guard::verify_gui_binary("osascript")
        .unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
    let result = Command::new(&binary_path)
        .args(["-l", "JavaScript", "-e", &js])
        .output();
    match result {
        Ok(output) if output.status.success() => {
            let pass = String::from_utf8_lossy(&output.stdout)
                .trim_end_matches(&['\n', '\r'][..])
                .to_string();
            if pass.is_empty() {
                Err(Error::UserDenied)
            } else {
                Ok(Zeroizing::new(pass))
            }
        }
        _ => Err(Error::UserDenied),
    }
}

/// v0.2.0 two-prompt fallback. Loops on mismatch up to 3 times.
fn macos_passphrase_create_two_prompt(
    prev_error: Option<&str>,
) -> Result<Zeroizing<String>, Error> {
    let mut hint = prev_error.map(str::to_string);
    for _attempt in 0..3 {
        let prompt = match &hint {
            Some(err) => format!(
                "{}\n\nCreate a vault passphrase (min 8 chars):",
                escape_osa(err)
            ),
            None => "Create a vault passphrase (min 8 chars):".to_string(),
        };
        let first = macos_password_prompt(&prompt)?;
        let confirm = macos_password_prompt("Confirm passphrase (must match exactly):")?;
        if first.as_str() == confirm.as_str() {
            return Ok(first);
        }
        hint = Some("Passphrases did not match. Try again.".to_string());
    }
    Err(Error::UserDenied)
}

fn macos_password_prompt(prompt: &str) -> Result<Zeroizing<String>, Error> {
    let script = format!(
        r#"display dialog "{prompt}" with title "envseal" default answer "" with hidden answer buttons {{"Cancel", "OK"}} default button "OK""#
    );
    let binary_path = guard::verify_gui_binary("osascript")
        .unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
    let result = Command::new(&binary_path).args(["-e", &script]).output();
    match result {
        Ok(output) if output.status.success() => {
            let out = String::from_utf8_lossy(&output.stdout);
            if let Some(pass) = out.strip_prefix("text returned:") {
                let passphrase = pass.trim_end_matches(&['\n', '\r'][..]).to_string();
                if passphrase.is_empty() {
                    Err(Error::UserDenied)
                } else {
                    Ok(Zeroizing::new(passphrase))
                }
            } else {
                Err(Error::UserDenied)
            }
        }
        _ => Err(Error::UserDenied),
    }
}

pub(crate) fn macos_secret_value(
    key_name: &str,
    description: &str,
) -> Result<Zeroizing<String>, Error> {
    let text = format!(
        "Paste value for secret '{}':\n{}",
        escape_osa(key_name),
        escape_osa(description)
    );
    let script = format!(
        r#"display dialog "{text}" with title "envseal — Store Secret" default answer "" buttons {{"Cancel", "Store"}} default button "Store""#
    );
    let binary_path = guard::verify_gui_binary("osascript")
        .unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
    let result = Command::new(&binary_path).args(["-e", &script]).output();
    match result {
        Ok(output) if output.status.success() => {
            let out = String::from_utf8_lossy(&output.stdout);
            if let Some(pass) = out.strip_prefix("text returned:") {
                let passphrase = pass.trim_end_matches(&['\n', '\r'][..]).to_string();
                if passphrase.is_empty() {
                    Err(Error::UserDenied)
                } else {
                    Ok(Zeroizing::new(passphrase))
                }
            } else {
                Err(Error::UserDenied)
            }
        }
        _ => Err(Error::UserDenied),
    }
}

pub(crate) fn macos_popup(
    binary_path: &str,
    command: &str,
    secret_name: &str,
    env_var: &str,
    warnings: &str,
) -> Result<Approval, Error> {
    let text = format!("Approval needed to inject secret.\n\nBinary: {binary_path}\nCommand: {command}\nSecret: {secret_name}\nEnv Var: {env_var}\n{warnings}");
    let script = format!(
        r#"display dialog "{}" with title "envseal — Inject Secret" buttons {{"Deny", "Allow Always", "Allow Once"}} default button "Allow Once""#,
        escape_osa(&text)
    );
    let binary_path = guard::verify_gui_binary("osascript")
        .unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
    let result = Command::new(&binary_path).args(["-e", &script]).output();
    match result {
        Ok(output) if output.status.success() => {
            let out = String::from_utf8_lossy(&output.stdout);
            if out.contains("button returned:Allow Once") {
                return Ok(Approval::AllowOnce);
            }
            if out.contains("button returned:Allow Always") {
                return Ok(Approval::AllowAlways);
            }
            Err(Error::UserDenied)
        }
        _ => Err(Error::UserDenied),
    }
}

// Returning `Result` keeps the per-platform preexec-prompt signature
// uniform with Linux/Windows, which can fail-on-IO. macOS's osascript
// path doesn't surface those errors as `Err` (we treat any failure as
// "user picked Skip"), so the wrapper looks unnecessary to clippy —
// silence it because the signature alignment matters.
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn macos_preexec_prompt(message: &str) -> Result<super::PreexecChoice, Error> {
    let script = format!(
        r#"display dialog "{}" with title "envseal — Migrate API key" buttons {{"Don't ask again", "Not now", "Store in vault"}} default button "Store in vault""#,
        escape_osa(message)
    );
    let binary_path = guard::verify_gui_binary("osascript")
        .unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
    let result = Command::new(&binary_path).args(["-e", &script]).output();
    match result {
        Ok(output) if output.status.success() => {
            let out = String::from_utf8_lossy(&output.stdout);
            if out.contains("button returned:Store in vault") {
                Ok(super::PreexecChoice::Store)
            } else if out.contains("button returned:Don't ask again") {
                Ok(super::PreexecChoice::DontAskAgain)
            } else {
                Ok(super::PreexecChoice::Skip)
            }
        }
        _ => Ok(super::PreexecChoice::Skip),
    }
}

pub(crate) fn macos_fido2_pin_entry(
    retries_left: u32,
    attempt: u32,
) -> Result<Zeroizing<String>, Error> {
    let text = escape_osa(&format!(
        "Enter the PIN for your security key (attempt {attempt}; {retries_left} authenticator retries remaining)"
    ));
    let script = format!(
        "display dialog \"{text}\" default answer \"\" with hidden answer \
         with title \"envseal - FIDO2 PIN\" buttons {{\"Cancel\", \"Continue\"}} \
         default button \"Continue\""
    );
    let output = Command::new("/usr/bin/osascript")
        .args(["-e", &script])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("osascript failed: {e}")))?;
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    if let Some(pos) = stdout.find("text returned:") {
        let pin = stdout[pos + 14..].trim().to_string();
        if pin.is_empty() {
            return Err(Error::UserDenied);
        }
        Ok(Zeroizing::new(pin))
    } else {
        Err(Error::UserDenied)
    }
}

pub(crate) fn macos_totp_entry(attempt: u32) -> Result<String, Error> {
    let text = escape_osa(&format!(
        "Enter your 6-digit authenticator code (attempt {attempt}/3)"
    ));
    let script = format!("display dialog \"{text}\" default answer \"\" with title \"envseal - TOTP Verification\" buttons {{\"Cancel\", \"Verify\"}} default button \"Verify\"");
    let output = Command::new("/usr/bin/osascript")
        .args(["-e", &script])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("osascript failed: {e}")))?;
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    if let Some(pos) = stdout.find("text returned:") {
        Ok(stdout[pos + 14..].trim().to_string())
    } else {
        Err(Error::UserDenied)
    }
}