#![cfg(target_os = "macos")]
use super::Approval;
use crate::error::Error;
use crate::guard;
use std::process::Command;
use zeroize::Zeroizing;
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)
}
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)
}
fn macos_passphrase_create_jxa(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
let prefix = prev_error.map(escape_osa).unwrap_or_default();
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),
}
}
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),
}
}
#[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)
}
}