// Copyright (C) 2026, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Diagnostics; using System.Runtime.Versioning; using System.Security.Cryptography.X509Certificates; using Duplicati.Library.Utility; namespace Duplicati.Library.Certificates.Platform; /// /// Installs CA certificates in the macOS Keychain using the security CLI tool. /// [SupportedOSPlatform("macos")] public class MacOSCATrustInstaller : ICATrustInstaller { /// /// Default keychain path. /// public const string DEFAULT_KEYCHAIN_PATH = "~/Library/Keychains/login.keychain-db"; /// /// The timeout for the security command. /// private static readonly TimeSpan PROCESS_TIMEOUT = TimeSpan.FromSeconds(30); /// /// Gets or sets the keychain path. /// Defaults to ~/Library/Keychains/login.keychain-db. /// public string KeychainPath { get; set; } = DEFAULT_KEYCHAIN_PATH; /// public string Name => "macOS Keychain"; /// public bool CanInstall() => OperatingSystem.IsMacOS(); /// public bool IsInstalled(X509Certificate2 caCertificate) { if (!OperatingSystem.IsMacOS()) return false; try { var hash = caCertificate.GetCertHashString(); // Extract the common name (CN) from the subject for filtering // The -c option filters by common name (partial match) var commonName = caCertificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); if (string.IsNullOrEmpty(commonName)) return false; // Find certificates matching the common name, with SHA-1 hashes // -a = all matches, -c = filter by common name, -Z = include SHA-1 hashes var result = RunSecurityCommand("find-certificate", "-a", "-c", commonName, "-Z", ResolveKeyChainPath(KeychainPath)); // Search for the SHA-1 hash line containing our specific hash if (result.ExitCode == 0) { var lines = result.Output.Split('\n'); foreach (var line in lines) { if (line.StartsWith("SHA-1 hash:", StringComparison.OrdinalIgnoreCase)) { var hashInOutput = line[("SHA-1 hash:".Length)..].Trim(); if (hashInOutput.Equals(hash, StringComparison.OrdinalIgnoreCase)) return true; } } } return false; } catch { return false; } } /// public TrustInstallationResult Install(X509Certificate2 caCertificate) { if (!OperatingSystem.IsMacOS()) return TrustInstallationResult.Failed; // Check if already installed if (IsInstalled(caCertificate)) return TrustInstallationResult.AlreadyInstalled; // Export certificate to a temporary file using var tempFile = new TempFile(); try { // Export certificate in DER format File.WriteAllBytes(tempFile, caCertificate.RawData); // Add certificate to keychain using 'security import' with explicit x509 format var addResult = RunSecurityCommand("import", tempFile, "-k", ResolveKeyChainPath(KeychainPath), "-t", "cert", "-f", "x509"); if (addResult.ExitCode != 0) { if (addResult.Error.Contains("User interaction is not allowed", StringComparison.OrdinalIgnoreCase) || addResult.Error.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return TrustInstallationResult.RequiresElevation; } return TrustInstallationResult.Failed; } // Trust the certificate, this requires elevated privileges var trustResult = RunSecurityCommand( "add-trusted-cert", "-d", "-r", "trustRoot", "-k", ResolveKeyChainPath(KeychainPath), tempFile); if (trustResult.ExitCode != 0) { if (trustResult.Error.Contains("User interaction is not allowed", StringComparison.OrdinalIgnoreCase) || trustResult.Error.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return TrustInstallationResult.RequiresElevation; } // Non-fatal - certificate is added but may need manual trust setting } return TrustInstallationResult.Success; } catch { return TrustInstallationResult.Failed; } } /// public TrustUninstallationResult Uninstall(X509Certificate2 caCertificate) { if (!OperatingSystem.IsMacOS()) return TrustUninstallationResult.Failed; // Check if installed if (!IsInstalled(caCertificate)) return TrustUninstallationResult.NotInstalled; try { // Delete certificate by SHA-1 hash to avoid accidentally removing // other certificates that share the same subject name. var hash = caCertificate.GetCertHashString(); var result = RunSecurityCommand("delete-certificate", "-t", "-Z", hash, ResolveKeyChainPath(KeychainPath)); if (result.ExitCode == 0) return TrustUninstallationResult.Success; if (result.Error.Contains("User interaction is not allowed", StringComparison.OrdinalIgnoreCase) || result.Error.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return TrustUninstallationResult.RequiresElevation; } return TrustUninstallationResult.Failed; } catch { return TrustUninstallationResult.Failed; } } private static string ResolveKeyChainPath(string path) { if (path.StartsWith("~/") || path == "~") { var home = Environment.GetEnvironmentVariable("HOME"); if (string.IsNullOrWhiteSpace(home)) home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); return home + path.Substring(1); } return path; } private static (int ExitCode, string Output, string Error) RunSecurityCommand(params string[] arguments) { var psi = new ProcessStartInfo { FileName = "security", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; // Use ArgumentList to safely pass arguments (handles escaping automatically) foreach (var arg in arguments) psi.ArgumentList.Add(arg); using var process = Process.Start(psi); if (process == null) return (-1, string.Empty, "Failed to start security process"); // Read stdout/stderr to completion before waiting for exit to avoid // deadlocks when the process output fills the OS pipe buffer. var outputTask = process.StandardOutput.ReadToEndAsync(); var errorTask = process.StandardError.ReadToEndAsync(); if (!Task.WaitAll([outputTask, errorTask], PROCESS_TIMEOUT)) { try { process.Kill(); } catch { // Ignore kill errors } return (-1, string.Empty, $"Security process timed out after {PROCESS_TIMEOUT}"); } process.WaitForExit(PROCESS_TIMEOUT); if (!process.HasExited) { try { process.Kill(); } catch { // Ignore kill errors } return (-1, string.Empty, $"Security process timed out after {PROCESS_TIMEOUT}"); } return (process.ExitCode, outputTask.Result, errorTask.Result); } }