Files
duplicati/Duplicati/Library/Certificates/Platform/LinuxCATrustInstaller.cs
T

260 lines
9.0 KiB
C#

// Copyright (C) 2025, 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;
namespace Duplicati.Library.Certificates.Platform;
/// <summary>
/// Installs CA certificates on Linux using the ca-certificates system.
/// </summary>
[SupportedOSPlatform("linux")]
public class LinuxCATrustInstaller : ICATrustInstaller
{
/// <summary>
/// Default certificate directory path.
/// </summary>
public const string DEFAULT_CERT_DIR = "/usr/local/share/ca-certificates";
/// <summary>
/// The certificate filename prefix.
/// </summary>
private const string CERT_FILENAME_PREFIX = "duplicati-local-ca";
/// <summary>
/// The timeout for the update-ca-certificates command.
/// </summary>
private static readonly TimeSpan PROCESS_TIMEOUT = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the certificate directory path.
/// Defaults to /usr/local/share/ca-certificates.
/// </summary>
public string CertDirectory { get; set; } = DEFAULT_CERT_DIR;
/// <summary>
/// Generates a unique certificate filename based on the certificate thumbprint.
/// </summary>
/// <param name="caCertificate">The CA certificate to generate the filename for.</param>
/// <returns>A unique filename for the certificate.</returns>
private static string GetCertFilename(X509Certificate2 caCertificate)
{
// Use first 8 chars of thumbprint for uniqueness while keeping filename readable
var thumbprintPrefix = caCertificate.Thumbprint[..8].ToLowerInvariant();
return $"{CERT_FILENAME_PREFIX}-{thumbprintPrefix}.crt";
}
/// <inheritdoc />
public string Name => "Linux ca-certificates";
/// <inheritdoc />
public bool CanInstall() => OperatingSystem.IsLinux();
/// <inheritdoc />
public bool IsInstalled(X509Certificate2 caCertificate)
{
if (!OperatingSystem.IsLinux())
return false;
try
{
var certPath = Path.Combine(CertDirectory, GetCertFilename(caCertificate));
if (!File.Exists(certPath))
return false;
// Read and compare certificate (file is stored in PEM format)
var pemText = File.ReadAllText(certPath);
var installedCert = CertificateStorageHelper.ImportFromPem(pemText);
return installedCert.Thumbprint.Equals(caCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
/// <inheritdoc />
public TrustInstallationResult Install(X509Certificate2 caCertificate)
{
if (!OperatingSystem.IsLinux())
return TrustInstallationResult.Failed;
// Check if already installed
if (IsInstalled(caCertificate))
return TrustInstallationResult.AlreadyInstalled;
// Check if cert directory exists - fail if it doesn't
if (!Directory.Exists(CertDirectory))
return TrustInstallationResult.Failed;
var certPath = Path.Combine(CertDirectory, GetCertFilename(caCertificate));
try
{
// Export certificate as PEM
var pem = CertificateStorageHelper.ExportToPem(caCertificate);
File.WriteAllText(certPath, pem);
// Run update-ca-certificates
var result = RunProcess("update-ca-certificates", string.Empty);
if (result.ExitCode != 0)
{
// Check for permission errors
if (result.Error.Contains("permission", StringComparison.OrdinalIgnoreCase) ||
result.Error.Contains("denied", StringComparison.OrdinalIgnoreCase) ||
result.Error.Contains("could not open", StringComparison.OrdinalIgnoreCase))
{
// Clean up the file we created
try
{
File.Delete(certPath);
}
catch
{
// Ignore cleanup errors
}
return TrustInstallationResult.RequiresElevation;
}
// Certificate file was written but update-ca-certificates failed,
// meaning the certificate is NOT actually trusted by the system.
// Clean up and report failure.
try
{
File.Delete(certPath);
}
catch
{
// Ignore cleanup errors
}
return TrustInstallationResult.Failed;
}
return TrustInstallationResult.Success;
}
catch (UnauthorizedAccessException)
{
return TrustInstallationResult.RequiresElevation;
}
catch
{
return TrustInstallationResult.Failed;
}
}
/// <inheritdoc />
public TrustUninstallationResult Uninstall(X509Certificate2 caCertificate)
{
if (!OperatingSystem.IsLinux())
return TrustUninstallationResult.Failed;
// Check if installed
if (!IsInstalled(caCertificate))
return TrustUninstallationResult.NotInstalled;
var certPath = Path.Combine(CertDirectory, GetCertFilename(caCertificate));
try
{
// Delete the certificate file
File.Delete(certPath);
// Run update-ca-certificates to remove from trust store
var result = RunProcess("update-ca-certificates", "--fresh");
if (result.ExitCode != 0)
{
if (result.Error.Contains("permission", StringComparison.OrdinalIgnoreCase) ||
result.Error.Contains("denied", StringComparison.OrdinalIgnoreCase))
{
return TrustUninstallationResult.RequiresElevation;
}
}
return TrustUninstallationResult.Success;
}
catch (UnauthorizedAccessException)
{
return TrustUninstallationResult.RequiresElevation;
}
catch (FileNotFoundException)
{
return TrustUninstallationResult.NotInstalled;
}
catch
{
return TrustUninstallationResult.Failed;
}
}
private static (int ExitCode, string Output, string Error) RunProcess(string fileName, string arguments)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
return (-1, string.Empty, $"Failed to start process: {fileName}");
// 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, $"Process timed out after {PROCESS_TIMEOUT}");
}
process.WaitForExit(PROCESS_TIMEOUT);
if (!process.HasExited)
{
try
{
process.Kill();
}
catch
{
// Ignore kill errors
}
return (-1, string.Empty, $"Process timed out after {PROCESS_TIMEOUT}");
}
return (process.ExitCode, outputTask.Result, errorTask.Result);
}
}