// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable enable
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Aspire.Cli;
using Aspire.Cli.Certificates;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Certificates.Generation;
/// <remarks>
/// On Unix, we trust the certificate in the following locations:
/// 1. dotnet (i.e. the CurrentUser/Root store)
/// 2. OpenSSL (i.e. adding it to a directory in $SSL_CERT_DIR)
/// 3. Firefox & Chromium (i.e. adding it to an NSS DB for each browser)
/// All of these locations are per-user.
/// </remarks>
internal sealed partial class UnixCertificateManager : CertificateManager
{
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
/// <summary>The name of an environment variable consumed by OpenSSL to locate certificates.</summary>
private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR";
private const string XdgConfigHomeVariableName = "XDG_CONFIG_HOME";
private const string OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY";
private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS";
private const string PowerShellCommand = "powershell.exe";
private const string WslInteropPath = "/proc/sys/fs/binfmt_misc/WSLInterop";
private const string WslInteropLatePath = "/proc/sys/fs/binfmt_misc/WSLInterop-late";
private const string WslFriendlyName = AspNetHttpsOidFriendlyName + " (WSL)";
private const string OpenSslCommand = "openssl";
private const int MaxHashCollisions = 10; // Something is going badly wrong if we have this many dev certs with the same hash
private HashSet<string>? _availableCommands;
private readonly IEnvironment _environment;
private readonly CertificateConfiguration.NssDbOverride? _nssDbOverride;
private readonly Action<ProcessStartInfo> _configureCertUtilStartInfo = static _ => { };
public UnixCertificateManager(ILogger logger, IEnvironment environment, CertificateConfiguration.NssDbOverride? nssDbOverride) : base(logger)
{
_environment = environment;
_nssDbOverride = nssDbOverride;
}
internal UnixCertificateManager(
ILogger logger,
IEnvironment environment,
CertificateConfiguration.NssDbOverride? nssDbOverride,
Action<ProcessStartInfo> configureCertUtilStartInfo)
: this(logger, environment, nssDbOverride)
{
_configureCertUtilStartInfo = configureCertUtilStartInfo;
}
internal UnixCertificateManager(string subject, int version)
: base(subject, version)
{
_environment = new Aspire.Cli.HostEnvironment();
_nssDbOverride = null;
}
public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
=> GetTrustLevel(certificate, CancellationToken.None);
internal TrustLevel GetTrustLevel(X509Certificate2 certificate, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var sawTrustSuccess = false;
var sawTrustFailure = false;
if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName)))
{
// Warn but don't bail.
Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName);
}
// Building the chain will check whether dotnet trusts the cert. We could, instead,
// enumerate the Root store and/or look for the file in the OpenSSL directory, but
// this tests the real-world behavior.
var chain = new X509Chain();
try
{
// This is just a heuristic for whether or not we should prompt the user to re-run with `--trust`
// so we don't need to check revocation (which doesn't really make sense for dev certs anyway)
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
if (chain.Build(certificate))
{
sawTrustSuccess = true;
}
else
{
sawTrustFailure = true;
Log.UnixNotTrustedByDotnet();
}
}
finally
{
// Disposing the chain does not dispose the elements we potentially built.
// Do the full walk manually to dispose.
for (var i = 0; i < chain.ChainElements.Count; i++)
{
chain.ChainElements[i].Certificate.Dispose();
}
chain.Dispose();
}
// Will become the name of the file on disk and the nickname in the NSS DBs
var certificateNickname = GetCertificateNickname(certificate);
var sslCertDirString = _environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
if (string.IsNullOrEmpty(sslCertDirString))
{
sawTrustFailure = true;
Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName);
}
else
{
var foundCert = false;
var sslCertDirs = sslCertDirString.Split(Path.PathSeparator);
foreach (var sslCertDir in sslCertDirs)
{
var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem");
if (File.Exists(certPath))
{
try
{
using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath);
if (AreCertificatesEqual(certificate, candidate))
{
foundCert = true;
break;
}
}
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
{
// Treat unreadable entries as a miss. A later SSL_CERT_DIR entry may still contain
// the expected certificate, so only report OpenSSL as untrusted after the full search.
}
}
}
if (foundCert)
{
sawTrustSuccess = true;
}
else
{
sawTrustFailure = true;
Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName);
}
}
var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
if (nssDbs.Count > 0)
{
if (!IsCommandAvailable(CertificateHelpers.CertUtilCommand))
{
// If there are browsers but we don't have certutil, we can't check trust and,
// in all probability, we can't have previously established it.
Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
sawTrustFailure = true;
}
else
{
foreach (var nssDb in nssDbs)
{
if (IsCertificateInNssDb(certificateNickname, nssDb, cancellationToken))
{
sawTrustSuccess = true;
}
else
{
sawTrustFailure = true;
Log.UnixNotTrustedByNss(nssDb.Path, nssDb.BrowserFamily);
}
}
}
}
// Success & Failure => Partial; Success => Full; Failure => None
return sawTrustSuccess
? sawTrustFailure
? TrustLevel.Partial
: TrustLevel.Full
: TrustLevel.None;
}
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
{
var export = certificate.Export(X509ContentType.Pkcs12, "");
certificate.Dispose();
certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);
Array.Clear(export, 0, export.Length);
using (var store = new X509Store(storeName, storeLocation))
{
store.Open(OpenFlags.ReadWrite);
store.Add(certificate);
store.Close();
}
;
return certificate;
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate)
{
// Return true as we don't perform any check.
// This is about checking storage, not trust.
return new CheckCertificateStateResult(true, null);
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
// Do nothing since we don't have anything to check here.
// This is about correcting storage, not trust.
}
internal override bool IsExportable(X509Certificate2 c) => true;
protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate)
{
var sawTrustFailure = false;
var sawTrustSuccess = false;
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
if (TryFindCertificateInStore(store, certificate, out _))
{
sawTrustSuccess = true;
}
else
{
try
{
using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
// FriendlyName is Windows-only, so we don't set it here.
store.Add(publicCertificate);
Log.UnixDotnetTrustSucceeded();
sawTrustSuccess = true;
}
catch (Exception ex)
{
sawTrustFailure = true;
Log.UnixDotnetTrustException(ex.Message);
}
}
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert
// to its final location in the OpenSSL directory. As a result, any failure up until that point
// is fatal (i.e. we can't trust the cert in other locations).
var certDir = GetOpenSslCertificateDirectory(homeDirectory)!; // May not exist
var nickname = GetCertificateNickname(certificate);
var certPath = Path.Combine(certDir, nickname) + ".pem";
var needToExport = true;
// We do our own check for file collisions since ExportCertificate silently overwrites.
if (File.Exists(certPath))
{
try
{
using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath);
if (!AreCertificatesEqual(existingCert, certificate))
{
Log.UnixNotOverwritingCertificate(certPath);
return TrustLevel.None;
}
needToExport = false; // If the bits are on disk, we don't need to re-export
}
catch
{
// If we couldn't load the file, then we also can't safely overwrite it.
Log.UnixNotOverwritingCertificate(certPath);
return TrustLevel.None;
}
}
if (needToExport)
{
// Security: we don't need the private key for trust, so we don't export it.
// Note that this will create directories as needed. We control `certPath`, so the permissions should be fine.
ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
}
// Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed.
var openSslTrustSucceeded = false;
var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand);
if (isOpenSslAvailable)
{
if (TryRehashOpenSslCertificates(certDir))
{
openSslTrustSucceeded = true;
}
}
else
{
Log.UnixMissingOpenSslCommand(OpenSslCommand);
}
if (openSslTrustSucceeded)
{
Log.UnixOpenSslTrustSucceeded();
sawTrustSuccess = true;
}
else
{
// The helpers log their own failure reasons - we just describe the consequences
Log.UnixOpenSslTrustFailed();
sawTrustFailure = true;
}
var nssDbs = GetNssDbs(homeDirectory);
if (nssDbs.Count > 0)
{
var isCertUtilAvailable = IsCommandAvailable(CertificateHelpers.CertUtilCommand);
if (!isCertUtilAvailable)
{
Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
// We'll loop over the nssdbs anyway so they'll be listed
}
foreach (var nssDb in nssDbs)
{
if (isCertUtilAvailable && TryAddCertificateToNssDb(certPath, nickname, nssDb))
{
if (IsCertificateInNssDb(nickname, nssDb))
{
Log.UnixNssDbTrustSucceeded(nssDb.Path);
sawTrustSuccess = true;
}
else
{
// If the dev cert is in the db under a different nickname, adding it will succeed (and probably even cause it to be trusted)
// but IsTrusted won't find it. This is unlikely to happen in practice, so we warn here, rather than hardening IsTrusted.
Log.UnixNssDbTrustFailedWithProbableConflict(nssDb.Path, nssDb.BrowserFamily);
sawTrustFailure = true;
}
}
else
{
Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.BrowserFamily);
sawTrustFailure = true;
}
}
}
if (sawTrustFailure)
{
if (sawTrustSuccess)
{
// Untrust throws in this case, but we're more lenient since a partially trusted state may be useful in practice.
Log.UnixTrustPartiallySucceeded();
}
else
{
return TrustLevel.None;
}
}
if (openSslTrustSucceeded)
{
Debug.Assert(IsCommandAvailable(OpenSslCommand), "How did we trust without the openssl command?");
var homeDirectoryWithSlash = homeDirectory[^1] == Path.DirectorySeparatorChar
? homeDirectory
: homeDirectory + Path.DirectorySeparatorChar;
var prettyCertDir = certDir.StartsWith(homeDirectoryWithSlash, StringComparison.Ordinal)
? Path.Combine("$HOME", certDir[homeDirectoryWithSlash.Length..])
: certDir;
var hasValidSslCertDir = false;
// Check if SSL_CERT_DIR is already set and if certDir is already included
var existingSslCertDir = _environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
if (!string.IsNullOrEmpty(existingSslCertDir))
{
var existingDirs = existingSslCertDir.Split(Path.PathSeparator);
var certDirFullPath = Path.GetFullPath(certDir);
var isCertDirIncluded = existingDirs.Any(dir =>
{
if (string.IsNullOrWhiteSpace(dir))
{
return false;
}
try
{
return string.Equals(Path.GetFullPath(dir), certDirFullPath, StringComparison.Ordinal);
}
catch
{
// Ignore invalid directory entries in SSL_CERT_DIR
return false;
}
});
if (isCertDirIncluded)
{
// The certificate directory is already in SSL_CERT_DIR, no action needed
Log.UnixOpenSslCertificateDirectoryAlreadyConfigured(prettyCertDir, OpenSslCertificateDirectoryVariableName);
hasValidSslCertDir = true;
}
else
{
// SSL_CERT_DIR is set but doesn't include our directory - suggest appending
Log.UnixSuggestAppendingToEnvironmentVariable(prettyCertDir, OpenSslCertificateDirectoryVariableName);
hasValidSslCertDir = false;
}
}
else if (TryGetOpenSslDirectory(out var openSslDir))
{
Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName);
hasValidSslCertDir = false;
}
else
{
Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName);
hasValidSslCertDir = false;
}
sawTrustFailure = !hasValidSslCertDir;
}
// Check to see if we're running in WSL; if so, use powershell.exe to add the certificate to the Windows trust store as well
if (IsRunningOnWslWithInterop())
{
try
{
if (TrustCertificateInWindowsStore(certificate))
{
Log.WslWindowsTrustSucceeded();
}
else
{
Log.WslWindowsTrustFailed();
sawTrustFailure = true;
}
}
catch (Exception ex)
{
Log.WslWindowsTrustException(ex.Message);
sawTrustFailure = true;
}
}
return sawTrustFailure
? TrustLevel.Partial
: TrustLevel.Full;
}
protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
var sawUntrustFailure = false;
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
if (TryFindCertificateInStore(store, certificate, out var matching))
{
try
{
store.Remove(matching);
}
catch (Exception ex)
{
Log.UnixDotnetUntrustException(ex.Message);
sawUntrustFailure = true;
}
}
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!;
// We don't attempt to remove the directory when it's empty - it's a standard location
// and will almost certainly be used in the future.
var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist
var nickname = GetCertificateNickname(certificate);
var certPath = Path.Combine(certDir, nickname) + ".pem";
if (File.Exists(certPath))
{
var openSslUntrustSucceeded = false;
var certificateFileDeleted = TryDeleteCertificateFile(certPath);
if (certificateFileDeleted)
{
if (IsCommandAvailable(OpenSslCommand))
{
openSslUntrustSucceeded = TryRehashOpenSslCertificates(certDir);
}
else
{
Log.UnixMissingOpenSslCommand(OpenSslCommand);
openSslUntrustSucceeded = true;
}
}
if (openSslUntrustSucceeded)
{
Log.UnixOpenSslUntrustSucceeded();
}
else
{
// The helpers log their own failure reasons - we just describe the consequences
Log.UnixOpenSslUntrustFailed();
sawUntrustFailure = true;
}
}
else
{
Log.UnixOpenSslUntrustSkipped(certPath);
}
var nssDbs = GetNssDbs(homeDirectory);
if (nssDbs.Count > 0)
{
var isCertUtilAvailable = IsCommandAvailable(CertificateHelpers.CertUtilCommand);
if (!isCertUtilAvailable)
{
Log.UnixMissingCertUtilCommand(CertificateHelpers.CertUtilCommand);
}
else
{
foreach (var nssDb in nssDbs)
{
if (TryRemoveCertificateFromNssDb(nickname, nssDb))
{
Log.UnixNssDbUntrustSucceeded(nssDb.Path);
}
else
{
Log.UnixNssDbUntrustFailed(nssDb.Path);
sawUntrustFailure = true;
}
}
}
}
if (sawUntrustFailure)
{
// It might be nice to include more specific error information in the exception message, but we've logged it anyway.
throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'.");
}
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);
}
protected override void CreateDirectoryWithPermissions(string directoryPath)
{
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
var dirInfo = new DirectoryInfo(directoryPath);
if (dirInfo.Exists)
{
if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
{
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
}
}
else
{
DirectoryHelper.CreateWithOwnerOnlyPermissions(directoryPath);
}
#pragma warning restore CA1416 // Validate platform compatibility
}
private bool IsCommandAvailable(string command)
{
_availableCommands ??= FindAvailableCommands();
return _availableCommands.Contains(command);
}
private HashSet<string> FindAvailableCommands()
{
var availableCommands = new HashSet<string>();
// We need OpenSSL 1.1.1h or newer (to pick up https://github.com/openssl/openssl/pull/12357),
// but, given that all of v1 is EOL, it doesn't seem worthwhile to check the version.
var commands = new[] { OpenSslCommand, CertificateHelpers.CertUtilCommand };
var environmentVariables = _environment.GetEnvironmentVariables()
.Where(kv => kv.Value is not null)
.ToDictionary(kv => kv.Name, kv => kv.Value!);
foreach (var command in commands)
{
if (PathLookupHelper.TryResolveExecutablePath(command, out _, environmentVariables))
{
availableCommands.Add(command);
}
}
return availableCommands;
}
private static string GetCertificateNickname(X509Certificate2 certificate)
{
return $"aspnetcore-localhost-{certificate.Thumbprint}";
}
/// <summary>
/// Detects if the current environment is Windows Subsystem for Linux (WSL) with interop enabled.
/// </summary>
/// <returns>True if running on WSL with interop; otherwise, false.</returns>
private bool IsRunningOnWslWithInterop()
{
// WSL exposes special files that indicate WSL interop is enabled.
// Either WSLInterop or WSLInterop-late may be present depending on the WSL version and configuration.
if (File.Exists(WslInteropPath) || File.Exists(WslInteropLatePath))
{
return true;
}
// Additionally check for standard WSL environment variables as a fallback.
// WSL_INTEROP is set to the path of the interop socket.
if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable("WSL_INTEROP")))
{
return true;
}
return false;
}
/// <summary>
/// Attempts to trust the certificate in the Windows certificate store via PowerShell when running on WSL.
/// If the certificate already exists in the store, this is a no-op.
/// </summary>
/// <param name="certificate">The certificate to trust.</param>
/// <returns>True if the certificate was successfully added to the Windows store; otherwise, false.</returns>
private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate)
{
// Export the certificate as DER-encoded bytes (no private key needed for trust)
// and embed it directly in the PowerShell script as Base64 to avoid file path
// translation issues between WSL and Windows.
var certBytes = certificate.Export(X509ContentType.Cert);
var certBase64 = Convert.ToBase64String(certBytes);
var escapedFriendlyName = WslFriendlyName.Replace("'", "''");
var powershellScript = $@"
$certBytes = [Convert]::FromBase64String('{certBase64}')
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)
$cert.FriendlyName = '{escapedFriendlyName}'
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
$store.Open('ReadWrite')
$store.Add($cert)
$store.Close()
";
// Encode the PowerShell script to Base64 (UTF-16LE as required by PowerShell)
var encodedCommand = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(powershellScript));
return Process.Run(
PowerShellCommand,
["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand],
silent: true).ExitCode == 0;
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
/// </remarks>
private bool IsCertificateInNssDb(string nickname, NssDb nssDb, CancellationToken cancellationToken = default)
{
// -V will validate that a cert can be used for a given purpose, in this case, server verification.
// There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence.
// (The docs suggest that "-V -u A" should do this, but it seems to accept all certs.)
try
{
return RunCertUtil(nssDb.CreateCheckProcessStartInfo(nickname), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Log.UnixNssDbCheckException(nssDb.Path, ex.Message);
// This method is used to determine whether more trust is needed, so it's better to underestimate the amount of trust.
return false;
}
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
/// </remarks>
private bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb)
{
// This silently clobbers an existing entry, so there's no need to check for existence first.
try
{
return RunCertUtil(nssDb.CreateAddProcessStartInfo(certificatePath, nickname));
}
catch (Exception ex)
{
Log.UnixNssDbAdditionException(nssDb.Path, ex.Message);
return false;
}
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="CertificateHelpers.CertUtilCommand"/> is available.
/// </remarks>
private bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb)
{
try
{
if (RunCertUtil(nssDb.CreateRemoveProcessStartInfo(nickname)))
{
return true;
}
// Maybe it wasn't in there because the overrides have change or trust only partially succeeded.
return !IsCertificateInNssDb(nickname, nssDb);
}
catch (Exception ex)
{
Log.UnixNssDbRemovalException(nssDb.Path, ex.Message);
return false;
}
}
private bool RunCertUtil(ProcessStartInfo startInfo, CancellationToken cancellationToken = default)
{
using var nullHandle = File.OpenNullHandle();
startInfo.StandardInputHandle = nullHandle;
startInfo.StandardOutputHandle = nullHandle;
startInfo.StandardErrorHandle = nullHandle;
_configureCertUtilStartInfo(startInfo);
// Not Process.Run/RunAsync: they only kill the root process when canceled. Trust checks are canceled
// on Ctrl+C, and certutil may be a wrapper script, so kill the whole tree to avoid leaking descendants.
using var process = Process.Start(startInfo)!;
try
{
process.WaitForExitAsync(cancellationToken).GetAwaiter().GetResult();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
try
{
process.Kill(entireProcessTree: true);
process.WaitForExit();
}
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception)
{
// The process either exited concurrently or could not be killed by this platform. Don't
// wait for it in the latter case: that would hang Ctrl+C on a process we can't stop.
}
throw;
}
return process.ExitCode == 0;
}
private string GetOpenSslCertificateDirectory(string homeDirectory)
{
var @override = _environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName);
if (!string.IsNullOrEmpty(@override))
{
Log.UnixOpenSslCertificateDirectoryOverridePresent(OpenSslCertDirectoryOverrideVariableName);
return @override;
}
return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust");
}
private bool TryDeleteCertificateFile(string certPath)
{
try
{
File.Delete(certPath);
return true;
}
catch (Exception ex)
{
Log.UnixCertificateFileDeletionException(certPath, ex.Message);
return false;
}
}
internal List<NssDb> GetNssDbs(string homeDirectory)
{
string nssDbOverrideSource;
string? nssDbOverride;
if (_nssDbOverride is { Value.Length: > 0 } configuredOverride)
{
nssDbOverrideSource = configuredOverride.Source;
nssDbOverride = configuredOverride.Value;
}
else
{
nssDbOverrideSource = NssDbOverrideVariableName;
nssDbOverride = _environment.GetEnvironmentVariable(NssDbOverrideVariableName);
}
return NssDb.Resolve(
homeDirectory,
_environment.GetEnvironmentVariable(XdgConfigHomeVariableName),
nssDbOverride,
nssDbOverrideSource,
this);
}
[GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
private static partial Regex OpenSslVersionRegex { get; }
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="OpenSslCommand"/> is available.
/// </remarks>
private bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir)
{
openSslDir = null;
try
{
var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
var processOutput = Process.RunAndCaptureText(processInfo);
if (processOutput.ExitStatus.ExitCode != 0)
{
Log.UnixOpenSslVersionFailed();
return false;
}
var match = OpenSslVersionRegex.Match(processOutput.StandardOutput);
if (!match.Success)
{
Log.UnixOpenSslVersionParsingFailed();
return false;
}
openSslDir = match.Groups[1].Value;
return true;
}
catch (Exception ex)
{
Log.UnixOpenSslVersionException(ex.Message);
return false;
}
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="OpenSslCommand"/> is available.
/// </remarks>
private bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash)
{
hash = null;
try
{
// c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again
// with -subject_hash_old. Old hashes are only needed for pre-1.0.0, so we skip that.
var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
var processOutput = Process.RunAndCaptureText(processInfo);
if (processOutput.ExitStatus.ExitCode != 0)
{
Log.UnixOpenSslHashFailed(certificatePath);
return false;
}
hash = processOutput.StandardOutput.Trim();
return true;
}
catch (Exception ex)
{
Log.UnixOpenSslHashException(certificatePath, ex.Message);
return false;
}
}
[GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")]
private static partial Regex OpenSslHashFilenameRegex { get; }
/// <remarks>
/// We only ever use .pem, but someone will eventually put their own cert in this directory,
/// so we should handle the same extensions as c_rehash (other than .crl).
/// </remarks>
[GeneratedRegex("\\.(pem|crt|cer)$")]
private static partial Regex OpenSslCertificateExtensionRegex { get; }
/// <remarks>
/// This is a simplified version of c_rehash from OpenSSL. Using the real one would require
/// installing the OpenSSL perl tools and perl itself, which might be annoying in a container.
/// </remarks>
private bool TryRehashOpenSslCertificates(string certificateDirectory)
{
try
{
// First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks.
var certs = new List<FileInfo>();
var dirInfo = new DirectoryInfo(certificateDirectory);
foreach (var file in dirInfo.EnumerateFiles())
{
var isSymlink = (file.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
if (isSymlink && OpenSslHashFilenameRegex.IsMatch(file.Name))
{
file.Delete();
}
else if (OpenSslCertificateExtensionRegex.IsMatch(file.Name))
{
certs.Add(file);
}
}
// Then, enumerate all certificates - there will usually be zero or one.
// c_rehash doesn't create additional symlinks for certs with the same fingerprint,
// but we don't expect this to happen, so we favor slightly slower look-ups when it
// does, rather than slightly slower rehashing when it doesn't.
foreach (var cert in certs)
{
if (!TryGetOpenSslHash(cert.FullName, out var hash))
{
return false;
}
var linkCreated = false;
for (var i = 0; i < MaxHashCollisions; i++)
{
var linkPath = Path.Combine(certificateDirectory, $"{hash}.{i}");
if (!File.Exists(linkPath))
{
// As in c_rehash, we link using a relative path.
File.CreateSymbolicLink(linkPath, cert.Name);
linkCreated = true;
break;
}
}
if (!linkCreated)
{
Log.UnixOpenSslRehashTooManyHashes(cert.FullName, hash, MaxHashCollisions);
return false;
}
}
}
catch (Exception ex)
{
Log.UnixOpenSslRehashException(ex.Message);
return false;
}
return true;
}
internal abstract class NssDb(string path)
{
public string Path => path;
public abstract string BrowserFamily { get; }
public abstract IReadOnlyList<string> CheckArguments { get; }
public abstract string TrustUsage { get; }
internal ProcessStartInfo CreateCheckProcessStartInfo(string nickname)
{
var startInfo = CreateProcessStartInfo(nickname);
foreach (var argument in CheckArguments)
{
startInfo.ArgumentList.Add(argument);
}
return startInfo;
}
internal ProcessStartInfo CreateAddProcessStartInfo(string certificatePath, string nickname)
{
var startInfo = CreateProcessStartInfo(nickname);
startInfo.ArgumentList.Add("-A");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(certificatePath);
startInfo.ArgumentList.Add("-t");
startInfo.ArgumentList.Add($"{TrustUsage},,");
return startInfo;
}
internal ProcessStartInfo CreateRemoveProcessStartInfo(string nickname)
{
var startInfo = CreateProcessStartInfo(nickname);
startInfo.ArgumentList.Add("-D");
return startInfo;
}
internal static List<NssDb> Resolve(
string homeDirectory,
string? xdgConfigHome,
string? nssDbOverride,
string nssDbOverrideVariableName,
UnixCertificateManager manager)
{
var nssDbs = new List<NssDb>();
if (TryGetOverrides(homeDirectory, xdgConfigHome, nssDbOverride, nssDbOverrideVariableName, nssDbs, manager))
{
// Overrides replace discovery so trust and cleanup use the caller's complete set of NSS databases.
return nssDbs;
}
if (!Directory.Exists(homeDirectory))
{
manager.Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName);
return nssDbs;
}
ChromiumNssDb.AddDiscoveredNssDbs(homeDirectory, nssDbs);
FirefoxNssDb.AddDiscoveredNssDbs(homeDirectory, xdgConfigHome, nssDbs, manager);
return nssDbs;
}
protected static bool TryRemovePrefix(string path, string prefix, out string unprefixedPath)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
unprefixedPath = path[prefix.Length..];
return true;
}
unprefixedPath = path;
return false;
}
private ProcessStartInfo CreateProcessStartInfo(string nickname)
{
var startInfo = new ProcessStartInfo(CertificateHelpers.CertUtilCommand);
startInfo.ArgumentList.Add("-d");
startInfo.ArgumentList.Add($"sql:{Path}");
startInfo.ArgumentList.Add("-n");
startInfo.ArgumentList.Add(nickname);
return startInfo;
}
private static bool TryGetOverrides(
string homeDirectory,
string? xdgConfigHome,
string? nssDbOverride,
string nssDbOverrideVariableName,
List<NssDb> nssDbs,
UnixCertificateManager manager)
{
if (string.IsNullOrEmpty(nssDbOverride))
{
return false;
}
manager.Log.UnixNssDbOverridePresent(nssDbOverrideVariableName);
var paths = nssDbOverride.Split(System.IO.Path.PathSeparator);
foreach (var path in paths)
{
if (FirefoxNssDb.TryRemoveOverridePrefix(path, out var unprefixedPath))
{
AddOverride(unprefixedPath, static nssDbPath => new FirefoxNssDb(nssDbPath), nssDbOverrideVariableName, nssDbs, manager);
continue;
}
if (ChromiumNssDb.TryRemoveOverridePrefix(path, out unprefixedPath))
{
AddOverride(unprefixedPath, static nssDbPath => new ChromiumNssDb(nssDbPath), nssDbOverrideVariableName, nssDbs, manager);
continue;
}
AddOverride(
path,
nssDbPath => FirefoxNssDb.IsProfilePath(homeDirectory, xdgConfigHome, nssDbPath)
? new FirefoxNssDb(nssDbPath)
: new ChromiumNssDb(nssDbPath),
nssDbOverrideVariableName,
nssDbs,
manager);
}
return true;
}
private static void AddOverride(
string path,
Func<string, NssDb> createNssDb,
string nssDbOverrideVariableName,
List<NssDb> nssDbs,
UnixCertificateManager manager)
{
if (string.IsNullOrEmpty(path))
{
return;
}
var nssDbPath = System.IO.Path.GetFullPath(path);
if (!Directory.Exists(nssDbPath))
{
manager.Log.UnixNssDbDoesNotExist(nssDbPath, nssDbOverrideVariableName);
return;
}
nssDbs.Add(createNssDb(nssDbPath));
}
}
private sealed class ChromiumNssDb(string path) : NssDb(path)
{
private const string OverridePrefixValue = "chromium=";
private static readonly IReadOnlyList<string> s_checkArgumentValues = ["-V", "-u", "V"];
public override string BrowserFamily => "Chromium";
public override IReadOnlyList<string> CheckArguments => s_checkArgumentValues;
public override string TrustUsage => "P";
public static bool TryRemoveOverridePrefix(string path, out string unprefixedPath)
=> TryRemovePrefix(path, OverridePrefixValue, out unprefixedPath);
public static void AddDiscoveredNssDbs(string homeDirectory, List<NssDb> nssDbs)
{
// Chrome, Chromium, and Edge all use this directory.
var chromiumNssDb = System.IO.Path.Combine(homeDirectory, ".pki", "nssdb");
if (Directory.Exists(chromiumNssDb))
{
nssDbs.Add(new ChromiumNssDb(chromiumNssDb));
}
// Chromium Snap uses this directory when launched under snap confinement.
var chromiumSnapNssDb = System.IO.Path.Combine(homeDirectory, "snap", "chromium", "current", ".pki", "nssdb");
if (Directory.Exists(chromiumSnapNssDb))
{
nssDbs.Add(new ChromiumNssDb(chromiumSnapNssDb));
}
}
}
private sealed class FirefoxNssDb(string path) : NssDb(path)
{
private const string OverridePrefixValue = "firefox=";
private static readonly IReadOnlyList<string> s_checkArgumentValues = ["-L"];
public override string BrowserFamily => "Firefox";
public override IReadOnlyList<string> CheckArguments => s_checkArgumentValues;
// Firefox doesn't seem to respect the more correct "trusted peer" (P) usage.
public override string TrustUsage => "C";
public static bool TryRemoveOverridePrefix(string path, out string unprefixedPath)
=> TryRemovePrefix(path, OverridePrefixValue, out unprefixedPath);
public static void AddDiscoveredNssDbs(
string homeDirectory,
string? xdgConfigHome,
List<NssDb> nssDbs,
UnixCertificateManager manager)
{
// Firefox continues using its legacy profile root when present and only uses XDG for new profiles.
var firefoxDirectory = GetLegacyDirectory(homeDirectory);
if (!Directory.Exists(firefoxDirectory))
{
firefoxDirectory = GetXdgDirectory(homeDirectory, xdgConfigHome);
}
AddProfiles(firefoxDirectory, nssDbs, manager);
AddProfiles(GetSnapDirectory(homeDirectory), nssDbs, manager);
}
public static bool IsProfilePath(string homeDirectory, string? xdgConfigHome, string nssDbPath)
{
if (IsPathInDirectory(nssDbPath, GetLegacyDirectory(homeDirectory)) ||
IsPathInDirectory(nssDbPath, GetXdgDirectory(homeDirectory, xdgConfigHome)) ||
IsPathInDirectory(nssDbPath, GetSnapDirectory(homeDirectory)))
{
return true;
}
var separator = System.IO.Path.DirectorySeparatorChar;
return nssDbPath.Contains($"{separator}.mozilla{separator}firefox{separator}", StringComparison.Ordinal) ||
nssDbPath.Contains($"{separator}.config{separator}mozilla{separator}firefox{separator}", StringComparison.Ordinal);
}
private static string GetLegacyDirectory(string homeDirectory)
=> System.IO.Path.Combine(homeDirectory, ".mozilla", "firefox");
private static string GetXdgDirectory(string homeDirectory, string? xdgConfigHome)
{
var configDirectory = !string.IsNullOrEmpty(xdgConfigHome) && System.IO.Path.IsPathFullyQualified(xdgConfigHome)
? xdgConfigHome
: System.IO.Path.Combine(homeDirectory, ".config");
return System.IO.Path.Combine(configDirectory, "mozilla", "firefox");
}
private static string GetSnapDirectory(string homeDirectory)
=> System.IO.Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox");
private static void AddProfiles(
string firefoxDirectory,
List<NssDb> nssDbs,
UnixCertificateManager manager)
{
if (!Directory.Exists(firefoxDirectory))
{
return;
}
foreach (var profileDirectory in GetProfiles(firefoxDirectory, manager))
{
nssDbs.Add(new FirefoxNssDb(profileDirectory));
}
}
private static IEnumerable<string> GetProfiles(string firefoxDirectory, UnixCertificateManager manager)
{
try
{
var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat(
Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly));
if (!profiles.Any())
{
manager.Log.UnixNoFirefoxProfilesFound(firefoxDirectory);
}
return profiles;
}
catch (Exception ex)
{
manager.Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message);
return [];
}
}
private static bool IsPathInDirectory(string path, string directory)
{
var directoryPrefix = System.IO.Path.TrimEndingDirectorySeparator(System.IO.Path.GetFullPath(directory)) + System.IO.Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return path.StartsWith(directoryPrefix, comparison);
}
}
}