#[cfg(target_os = "macos")]
use std::ffi::OsString;
#[cfg(target_os = "macos")]
use std::fs;
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use product_os_security::certificates::ManagedCa;
use product_os_utilities::ProductOSError;
use crate::config::NetworkProxyTrustTarget;
use crate::error::ProxyError;
pub struct TrustInstaller;
impl TrustInstaller {
pub fn install(
managed: &ManagedCa,
target: NetworkProxyTrustTarget,
trust: bool,
) -> Result<(), ProductOSError> {
#[cfg(target_os = "macos")]
match target {
NetworkProxyTrustTarget::System => {
return install_macos_system(managed.cert_path(), trust)
}
NetworkProxyTrustTarget::MacosUserKeychain => {
return install_macos_user(managed.cert_path(), trust);
}
NetworkProxyTrustTarget::Firefox | NetworkProxyTrustTarget::ChromiumProfile => {}
}
let cert_path = managed.cert_path().display().to_string();
let script = Self::install_script(target);
#[cfg(target_os = "windows")]
if script.extension().and_then(|e| e.to_str()) == Some("ps1") {
return install_windows_ps1(managed.cert_path(), trust);
}
let mut cmd = Command::new("sh");
cmd.arg(&script).arg(&cert_path);
if trust {
cmd.arg("--trust");
}
let output = cmd.output().map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!(
"failed to run install script {}: {e}",
script.display()
)))
})?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
format!(
"{stderr}{}",
if stdout.trim().is_empty() {
String::new()
} else {
format!("\n{stdout}")
}
)
};
Err(ProductOSError::from(ProxyError::Generic(format!(
"install script failed: {detail}"
))))
}
}
pub fn uninstall(
managed: &ManagedCa,
target: NetworkProxyTrustTarget,
) -> Result<(), ProductOSError> {
#[cfg(target_os = "macos")]
match target {
NetworkProxyTrustTarget::System => return uninstall_macos_system(managed.cert_path()),
NetworkProxyTrustTarget::MacosUserKeychain => {
return uninstall_macos_user(managed.cert_path());
}
NetworkProxyTrustTarget::Firefox | NetworkProxyTrustTarget::ChromiumProfile => {}
}
let cert_path = managed.cert_path().display().to_string();
let script = Self::uninstall_script(target);
let output = Command::new("sh")
.arg(&script)
.arg(&cert_path)
.output()
.map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!(
"failed to run uninstall script: {e}"
)))
})?;
if output.status.success() {
Ok(())
} else {
Err(ProductOSError::from(ProxyError::Generic(format!(
"uninstall script failed: {}",
String::from_utf8_lossy(&output.stderr)
))))
}
}
fn install_script(target: NetworkProxyTrustTarget) -> PathBuf {
match target {
NetworkProxyTrustTarget::System => {
#[cfg(target_os = "macos")]
{
return script_path("install-macos.sh");
}
#[cfg(target_os = "windows")]
{
return script_path("install-windows.ps1");
}
#[cfg(target_os = "linux")]
{
return script_path("install-linux-debian.sh");
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return script_path("install-linux-debian.sh");
}
}
NetworkProxyTrustTarget::Firefox => script_path("install-linux-nss.sh"),
NetworkProxyTrustTarget::ChromiumProfile => script_path("install-chromium-nss.sh"),
NetworkProxyTrustTarget::MacosUserKeychain => script_path("install-macos-user.sh"),
}
}
fn uninstall_script(target: NetworkProxyTrustTarget) -> PathBuf {
match target {
NetworkProxyTrustTarget::System => {
#[cfg(target_os = "macos")]
{
return script_path("uninstall-macos.sh");
}
#[cfg(target_os = "windows")]
{
return script_path("uninstall-windows.ps1");
}
#[cfg(target_os = "linux")]
{
return script_path("uninstall-linux-debian.sh");
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return script_path("uninstall-linux-debian.sh");
}
}
NetworkProxyTrustTarget::Firefox => script_path("uninstall-linux-nss.sh"),
NetworkProxyTrustTarget::ChromiumProfile => script_path("uninstall-chromium-nss.sh"),
NetworkProxyTrustTarget::MacosUserKeychain => script_path("uninstall-macos-user.sh"),
}
}
}
pub fn script_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("scripts/certs")
.join(name)
}
#[cfg(target_os = "macos")]
const MACOS_SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
#[cfg(target_os = "macos")]
const MACOS_UNINSTALL_CN_NAMES: &[&str] = &["Product OS development CA", "Product OS Proxy CA"];
#[cfg(target_os = "macos")]
const MACOS_PROMPT_INSTALL: &str =
"Product OS needs to install its development CA in the System keychain so Chrome trusts HTTPS traffic.";
#[cfg(target_os = "macos")]
const MACOS_PROMPT_UNINSTALL: &str =
"Product OS needs to remove its development CA from the System keychain.";
#[cfg(target_os = "macos")]
fn install_macos_system(cert_path: &Path, trust: bool) -> Result<(), ProductOSError> {
ensure_cert_exists(cert_path)?;
let mut args: Vec<OsString> = vec![
OsString::from("security"),
OsString::from(if trust {
"add-trusted-cert"
} else {
"add-certificates"
}),
];
if trust {
args.extend([
OsString::from("-d"),
OsString::from("-r"),
OsString::from("trustRoot"),
OsString::from("-p"),
OsString::from("ssl"),
]);
}
args.extend([
OsString::from("-k"),
OsString::from(MACOS_SYSTEM_KEYCHAIN),
cert_path.as_os_str().to_os_string(),
]);
run_macos_admin(&args, MACOS_PROMPT_INSTALL)
}
#[cfg(target_os = "macos")]
fn install_macos_user(cert_path: &Path, trust: bool) -> Result<(), ProductOSError> {
ensure_cert_exists(cert_path)?;
let login_keychain = macos_login_keychain()?;
let mut cmd = Command::new("security");
if trust {
cmd.args([
"add-trusted-cert",
"-d",
"-r",
"trustRoot",
"-p",
"ssl",
"-k",
]);
cmd.arg(&login_keychain).arg(cert_path);
} else {
cmd.args(["import", "-k"])
.arg(&login_keychain)
.arg(cert_path);
}
run_command(cmd, "macOS login keychain install")
}
#[cfg(target_os = "macos")]
fn uninstall_macos_system(cert_path: &Path) -> Result<(), ProductOSError> {
ensure_cert_exists(cert_path)?;
let mut hashes = Vec::new();
for cn in MACOS_UNINSTALL_CN_NAMES {
hashes.extend(sha1_hashes_for_cn(MACOS_SYSTEM_KEYCHAIN, cn)?);
}
if let Some(hash) = sha1_hash_from_cert(cert_path)? {
hashes.push(hash);
}
dedupe_nonempty(&mut hashes);
if hashes.is_empty() {
return Ok(());
}
let mut script = String::from("set -e\n");
for hash in hashes {
script.push_str(&format!(
"security delete-certificate -Z {} {} 2>/dev/null || true\n",
shell_single_quote(&hash),
shell_single_quote(MACOS_SYSTEM_KEYCHAIN)
));
}
run_macos_admin(
&[
OsString::from("/bin/sh"),
OsString::from("-c"),
OsString::from(script),
],
MACOS_PROMPT_UNINSTALL,
)
}
#[cfg(target_os = "macos")]
fn uninstall_macos_user(cert_path: &Path) -> Result<(), ProductOSError> {
ensure_cert_exists(cert_path)?;
let login_keychain = macos_login_keychain()?;
let mut hashes = Vec::new();
for cn in MACOS_UNINSTALL_CN_NAMES {
hashes.extend(sha1_hashes_for_cn(&login_keychain, cn)?);
}
if let Some(hash) = sha1_hash_from_cert(cert_path)? {
hashes.push(hash);
}
dedupe_nonempty(&mut hashes);
for hash in hashes {
let mut cmd = Command::new("security");
cmd.args(["delete-certificate", "-Z", &hash, &login_keychain]);
let _ = cmd.output();
}
Ok(())
}
#[cfg(target_os = "macos")]
fn ensure_cert_exists(cert_path: &Path) -> Result<(), ProductOSError> {
if cert_path.is_file() {
Ok(())
} else {
Err(ProductOSError::from(ProxyError::Generic(format!(
"certificate not found: {}",
cert_path.display()
))))
}
}
#[cfg(target_os = "macos")]
fn macos_login_keychain() -> Result<String, ProductOSError> {
let home = std::env::var("HOME").map_err(|_| {
ProductOSError::from(ProxyError::Generic(
"HOME not set for macOS login keychain path".into(),
))
})?;
Ok(format!("{home}/Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn sha1_hashes_for_cn(keychain: &str, cn: &str) -> Result<Vec<String>, ProductOSError> {
let output = Command::new("security")
.args(["find-certificate", "-a", "-c", cn, "-Z", keychain])
.output()
.map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!(
"security find-certificate failed: {e}"
)))
})?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().strip_prefix("SHA-1 hash:").map(str::trim))
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect())
}
#[cfg(target_os = "macos")]
fn sha1_hash_from_cert(cert_path: &Path) -> Result<Option<String>, ProductOSError> {
let output = Command::new("openssl")
.args(["x509", "-in"])
.arg(cert_path)
.args(["-noout", "-fingerprint", "-sha1"])
.output()
.map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!(
"openssl fingerprint probe failed: {e}"
)))
})?;
if !output.status.success() {
return Ok(None);
}
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some((_, value)) = line.split_once('=') {
let hash = value.trim().replace(':', "");
if !hash.is_empty() {
return Ok(Some(hash));
}
}
}
Ok(None)
}
#[cfg(target_os = "macos")]
fn dedupe_nonempty(values: &mut Vec<String>) {
use std::collections::HashSet;
let mut seen = HashSet::new();
values.retain(|value| {
let trimmed = value.trim();
!trimmed.is_empty() && seen.insert(trimmed.to_string())
});
}
#[cfg(target_os = "macos")]
fn run_command(mut cmd: Command, label: &str) -> Result<(), ProductOSError> {
let output = cmd.output().map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!("{label} failed to start: {e}")))
})?;
if output.status.success() {
return Ok(());
}
Err(ProductOSError::from(ProxyError::Generic(format!(
"{label} failed: {}",
command_detail(&output.stdout, &output.stderr)
))))
}
#[cfg(target_os = "macos")]
fn run_macos_admin(command_args: &[OsString], prompt: &str) -> Result<(), ProductOSError> {
let (uid, home) = macos_console_session()?;
let applescript = applescript_for(command_args, prompt);
let mut attempts = Vec::new();
let mut launchctl_cmd = Command::new("launchctl");
launchctl_cmd
.args(["asuser", &uid, "/usr/bin/osascript", "-e"])
.arg(&applescript)
.env("HOME", &home);
attempts.push(("launchctl-asuser", launchctl_cmd.output()));
let script_path = std::env::temp_dir().join(format!(
"product-os-admin-{}.applescript",
std::process::id()
));
let open_attempt = fs::write(&script_path, applescript.as_bytes()).and_then(|_| {
Command::new("launchctl")
.args(["asuser", &uid, "/usr/bin/open", "-W", "-a", "osascript"])
.arg(&script_path)
.env("HOME", &home)
.output()
});
attempts.push(("open-osascript", open_attempt));
let _ = fs::remove_file(&script_path);
attempts.push((
"direct-osascript",
Command::new("/usr/bin/osascript")
.arg("-e")
.arg(&applescript)
.output(),
));
for (_, attempt) in &attempts {
if let Ok(output) = attempt {
if output.status.success() {
return Ok(());
}
}
}
let last_detail = attempts
.last()
.and_then(|(_, attempt)| attempt.as_ref().ok())
.map(|output| command_detail(&output.stdout, &output.stderr))
.unwrap_or_default();
if last_detail.to_ascii_lowercase().contains("canceled") {
return Err(ProductOSError::from(ProxyError::Generic(
"macOS administrator password dialog was cancelled; system keychain operation requires approval".into(),
)));
}
if no_gui_error(&last_detail) {
return Err(ProductOSError::from(ProxyError::Generic(format!(
"macOS administrator password dialog could not be shown from this process; retry from an interactive session or approve the dialog when Product OS prompts via browser_ca_ensure_trust. ({last_detail})"
))));
}
let detail = attempts
.into_iter()
.map(|(name, attempt)| match attempt {
Ok(output) => format!("{name}: {}", command_detail(&output.stdout, &output.stderr)),
Err(err) => format!("{name}: {err}"),
})
.collect::<Vec<_>>()
.join("; ");
Err(ProductOSError::from(ProxyError::Generic(format!(
"admin command failed: {detail}"
))))
}
#[cfg(target_os = "macos")]
fn macos_console_session() -> Result<(String, String), ProductOSError> {
let user = command_stdout(Command::new("stat").args(["-f%Su", "/dev/console"]))?;
let uid = command_stdout(Command::new("stat").args(["-f%u", "/dev/console"]))?;
let home = std::env::var("HOME").unwrap_or_else(|_| format!("/Users/{user}"));
Ok((uid, home))
}
#[cfg(target_os = "macos")]
fn command_stdout(cmd: &mut Command) -> Result<String, ProductOSError> {
let output = cmd.output().map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!("command failed to start: {e}")))
})?;
if !output.status.success() {
return Err(ProductOSError::from(ProxyError::Generic(format!(
"command failed: {}",
command_detail(&output.stdout, &output.stderr)
))));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(target_os = "macos")]
fn applescript_for(command_args: &[OsString], prompt: &str) -> String {
let shell_cmd = command_args
.iter()
.map(|arg| shell_single_quote(&arg.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ");
let escaped_cmd = shell_cmd.replace('\\', "\\\\").replace('"', "\\\"");
let escaped_prompt = prompt.replace('\\', "\\\\").replace('"', "\\\"");
format!(
"do shell script \"{escaped_cmd}\" with administrator privileges with prompt \"{escaped_prompt}\""
)
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
#[cfg(target_os = "macos")]
fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
let stdout = String::from_utf8_lossy(stdout).trim().to_string();
if stderr.is_empty() {
stdout
} else if stdout.is_empty() {
stderr
} else {
format!("{stderr}\n{stdout}")
}
}
#[cfg(target_os = "macos")]
fn no_gui_error(detail: &str) -> bool {
let lowered = detail.to_ascii_lowercase();
[
"no user interaction was possible",
"user canceled",
"authorization was denied since no user interaction was possible",
]
.iter()
.any(|marker| lowered.contains(marker))
}
#[cfg(target_os = "windows")]
pub fn install_windows_ps1(cert_path: &Path, trust: bool) -> Result<(), ProductOSError> {
let script = script_path("install-windows.ps1");
let mut cmd = Command::new("powershell");
cmd.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(&script)
.arg(cert_path);
if trust {
cmd.arg("-Trust");
}
let output = cmd.output().map_err(|e| {
ProductOSError::from(ProxyError::Generic(format!(
"powershell install failed: {e}"
)))
})?;
if output.status.success() {
Ok(())
} else {
Err(ProductOSError::from(ProxyError::Generic(format!(
"windows install failed: {}",
String::from_utf8_lossy(&output.stderr)
))))
}
}