|
// 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.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
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 OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY";
private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS";
// CONSIDER: we could have a distinct variable for Mozilla NSS DBs, but detecting them from the path seems sufficient for now.
private const string BrowserFamilyChromium = "Chromium";
private const string BrowserFamilyFirefox = "Firefox";
private const string OpenSslCommand = "openssl";
private const string CertUtilCommand = "certutil";
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;
public UnixCertificateManager()
{
}
internal UnixCertificateManager(string subject, int version)
: base(subject, version)
{
}
public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
{
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.
using var chain = new X509Chain();
// 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();
}
// 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))
{
var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath);
if (AreCertificatesEqual(certificate, candidate))
{
foundCert = true;
break;
}
}
}
if (foundCert)
{
sawTrustSuccess = true;
}
else
{
sawTrustFailure = true;
Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName);
}
}
var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
if (nssDbs.Count > 0)
{
if (!IsCommandAvailable(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(CertUtilCommand);
sawTrustFailure = true;
}
else
{
foreach (var nssDb in nssDbs)
{
if (IsCertificateInNssDb(certificateNickname, nssDb))
{
sawTrustSuccess = true;
}
else
{
sawTrustFailure = true;
Log.UnixNotTrustedByNss(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium);
}
}
}
}
// 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 = new X509Certificate2(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.
}
protected 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 overwite 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(CertUtilCommand);
if (!isCertUtilAvailable)
{
Log.UnixMissingCertUtilCommand(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.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium);
sawTrustFailure = true;
}
}
else
{
Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium);
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;
if (TryGetOpenSslDirectory(out var openSslDir))
{
Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName);
}
else
{
Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName);
}
}
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;
if (IsCommandAvailable(OpenSslCommand))
{
if (TryDeleteCertificateFile(certPath) && TryRehashOpenSslCertificates(certDir))
{
openSslUntrustSucceeded = true;
}
}
else
{
Log.UnixMissingOpenSslCommand(OpenSslCommand);
}
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(CertUtilCommand);
if (!isCertUtilAvailable)
{
Log.UnixMissingCertUtilCommand(CertUtilCommand);
// We'll loop over the nssdbs anyway so they'll be listed
}
foreach (var nssDb in nssDbs)
{
if (isCertUtilAvailable && 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
{
Directory.CreateDirectory(directoryPath, DirectoryPermissions);
}
#pragma warning restore CA1416 // Validate platform compatibility
}
private static string GetChromiumNssDb(string homeDirectory)
{
return Path.Combine(homeDirectory, ".pki", "nssdb");
}
private static string GetChromiumSnapNssDb(string homeDirectory)
{
return Path.Combine(homeDirectory, "snap", "chromium", "current", ".pki", "nssdb");
}
private static string GetFirefoxDirectory(string homeDirectory)
{
return Path.Combine(homeDirectory, ".mozilla", "firefox");
}
private static string GetFirefoxSnapDirectory(string homeDirectory)
{
return Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox");
}
private bool IsCommandAvailable(string command)
{
_availableCommands ??= FindAvailableCommands();
return _availableCommands.Contains(command);
}
private static 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, CertUtilCommand };
var searchPath = Environment.GetEnvironmentVariable("PATH");
if (searchPath is null)
{
return availableCommands;
}
var searchFolders = searchPath.Split(Path.PathSeparator);
foreach (var searchFolder in searchFolders)
{
foreach (var command in commands)
{
if (!availableCommands.Contains(command))
{
try
{
if (File.Exists(Path.Combine(searchFolder, command)))
{
availableCommands.Add(command);
}
}
catch
{
// It's not interesting to report (e.g.) permission errors here.
}
}
}
// Stop early if we've found all the required commands.
// They're usually all in the same folder (/bin or /usr/bin).
if (availableCommands.Count == commands.Length)
{
break;
}
}
return availableCommands;
}
private static string GetCertificateNickname(X509Certificate2 certificate)
{
return $"aspnetcore-localhost-{certificate.Thumbprint}";
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="CertUtilCommand"/> is available.
/// </remarks>
private static bool IsCertificateInNssDb(string nickname, NssDb nssDb)
{
// -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.)
var operation = nssDb.IsFirefox ? "-L" : "-V -u V";
var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} {operation}")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
try
{
using var process = Process.Start(startInfo)!;
process.WaitForExit();
return process.ExitCode == 0;
}
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="CertUtilCommand"/> is available.
/// </remarks>
private static bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb)
{
// Firefox doesn't seem to respected the more correct "trusted peer" (P) usage, so we use "trusted CA" (C) instead.
var usage = nssDb.IsFirefox ? "C" : "P";
// This silently clobbers an existing entry, so there's no need to check for existence first.
var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} -A -i {certificatePath} -t \"{usage},,\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
try
{
using var process = Process.Start(startInfo)!;
process.WaitForExit();
return process.ExitCode == 0;
}
catch (Exception ex)
{
Log.UnixNssDbAdditionException(nssDb.Path, ex.Message);
return false;
}
}
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="CertUtilCommand"/> is available.
/// </remarks>
private static bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb)
{
var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -D -n {nickname}")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
try
{
using var process = Process.Start(startInfo)!;
process.WaitForExit();
if (process.ExitCode == 0)
{
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 static IEnumerable<string> GetFirefoxProfiles(string firefoxDirectory)
{
try
{
var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat(
Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly)); // There can be one of these for each release channel
if (!profiles.Any())
{
// This is noteworthy, given that we're in a firefox directory.
Log.UnixNoFirefoxProfilesFound(firefoxDirectory);
}
return profiles;
}
catch (Exception ex)
{
Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message);
return [];
}
}
private static 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 static bool TryDeleteCertificateFile(string certPath)
{
try
{
File.Delete(certPath);
return true;
}
catch (Exception ex)
{
Log.UnixCertificateFileDeletionException(certPath, ex.Message);
return false;
}
}
private static bool TryGetNssDbOverrides(out IReadOnlyList<string> overrides)
{
var nssDbOverride = Environment.GetEnvironmentVariable(NssDbOverrideVariableName);
if (string.IsNullOrEmpty(nssDbOverride))
{
overrides = [];
return false;
}
// Normally, we'd let the caller log this, since it's not really an exceptional condition,
// but it's not worth duplicating the code and the work.
Log.UnixNssDbOverridePresent(NssDbOverrideVariableName);
var nssDbs = new List<string>();
var paths = nssDbOverride.Split(Path.PathSeparator); // May be empty - the user may not want to add browser trust
foreach (var path in paths)
{
var nssDb = Path.GetFullPath(path);
if (!Directory.Exists(nssDb))
{
Log.UnixNssDbDoesNotExist(nssDb, NssDbOverrideVariableName);
continue;
}
nssDbs.Add(nssDb);
}
overrides = nssDbs;
return true;
}
private static List<NssDb> GetNssDbs(string homeDirectory)
{
var nssDbs = new List<NssDb>();
if (TryGetNssDbOverrides(out var nssDbOverrides))
{
foreach (var nssDb in nssDbOverrides)
{
// Our Firefox approach is a hack, so we'd rather under-recognize it than over-recognize it.
var isFirefox = nssDb.Contains("/.mozilla/firefox/", StringComparison.Ordinal);
nssDbs.Add(new NssDb(nssDb, isFirefox));
}
return nssDbs;
}
if (!Directory.Exists(homeDirectory))
{
Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName);
return nssDbs;
}
// Chrome, Chromium, and Edge all use this directory
var chromiumNssDb = GetChromiumNssDb(homeDirectory);
if (Directory.Exists(chromiumNssDb))
{
nssDbs.Add(new NssDb(chromiumNssDb, isFirefox: false));
}
// Chromium Snap, when launched under snap confinement, uses this directory
// (On Ubuntu, the GUI launcher uses confinement, but the terminal does not)
var chromiumSnapNssDb = GetChromiumSnapNssDb(homeDirectory);
if (Directory.Exists(chromiumSnapNssDb))
{
nssDbs.Add(new NssDb(chromiumSnapNssDb, isFirefox: false));
}
var firefoxDir = GetFirefoxDirectory(homeDirectory);
if (Directory.Exists(firefoxDir))
{
var profileDirs = GetFirefoxProfiles(firefoxDir);
foreach (var profileDir in profileDirs)
{
nssDbs.Add(new NssDb(profileDir, isFirefox: true));
}
}
var firefoxSnapDir = GetFirefoxSnapDirectory(homeDirectory);
if (Directory.Exists(firefoxSnapDir))
{
var profileDirs = GetFirefoxProfiles(firefoxSnapDir);
foreach (var profileDir in profileDirs)
{
nssDbs.Add(new NssDb(profileDir, isFirefox: true));
}
}
return nssDbs;
}
[GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
private static partial Regex OpenSslVersionRegex();
/// <remarks>
/// It is the caller's responsibility to ensure that <see cref="OpenSslCommand"/> is available.
/// </remarks>
private static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir)
{
openSslDir = null;
try
{
var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
using var process = Process.Start(processInfo);
var stdout = process!.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.UnixOpenSslVersionFailed();
return false;
}
var match = OpenSslVersionRegex().Match(stdout);
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 static 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
};
using var process = Process.Start(processInfo);
var stdout = process!.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.UnixOpenSslHashFailed(certificatePath);
return false;
}
hash = stdout.Trim();
return true;
}
catch (Exception ex)
{
Log.UnixOpenSslHashException(certificatePath, ex.Message);
return false;
}
}
[GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")]
private static partial Regex OpenSslHashFilenameRegex();
/// <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();
/// <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 static bool TryRehashOpenSslCertificates(string certificateDirectory)
{
try
{
// First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks.
var hashRegex = OpenSslHashFilenameRegex();
var extensionRegex = OpenSslCertificateExtensionRegex();
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 && hashRegex.IsMatch(file.Name))
{
file.Delete();
}
else if (extensionRegex.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;
}
private sealed class NssDb(string path, bool isFirefox)
{
public string Path => path;
public bool IsFirefox => isFirefox;
}
}
|