// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
using Aspire.Cli.Certificates;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Certificates.Generation;
/// <remarks>
/// Normally, we avoid the use of <see cref="X509Certificate2.Thumbprint"/> because it's a SHA-1 hash and, therefore,
/// not adequate for security applications. However, the MacOS security tool uses SHA-1 hashes for certificate
/// identification, so we're stuck.
/// </remarks>
internal sealed class MacOSCertificateManager : CertificateManager
{
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
// User keychain. Guard with quotes when using in command lines since users may have set
// their user profile (HOME) directory to a non-standard path that includes whitespace.
private static readonly string s_macOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
// System keychain. We no longer store certificates or create trust rules in the system
// keychain, but check for their presence here so that we can clean up state left behind
// by pre-.NET 7 versions of this tool.
private const string MacOSSystemKeychain = "/Library/Keychains/System.keychain";
// Well-known location on disk where dev-certs are stored.
private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https");
// Well-known location where Aspire.Hosting caches dev-cert key material to avoid
// triggering macOS Keychain access prompts at app-host startup time.
private static readonly string s_aspireDevCertsCacheDirectory = CertificateHelpers.AspireDevCertsHttpsCacheDirectory;
// Verify the certificate {0} for the SSL and X.509 Basic Policy.
private const string MacOSVerifyCertificateCommandLine = "security";
private const string MacOSVerifyCertificateCommandLineArgumentsFormat = "verify-cert -c \"{0}\" -p basic -p ssl";
// Delete a certificate with the specified SHA-256 (or SHA-1) hash {0} from keychain {1}.
private const string MacOSDeleteCertificateCommandLine = "sudo";
private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} \"{1}\"";
// Add a certificate to the per-user trust settings in the user keychain. The trust policy
// for the certificate will be set to be always trusted for SSL and X.509 Basic Policy.
// Note: This operation will require user authentication.
private const string MacOSTrustCertificateCommandLine = "security";
private static readonly string s_macOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k \"{s_macOSUserKeychain}\" ";
// Import a pkcs12 certificate into the user keychain using the unwrapping passphrase {1}, and
// allow any application to access the imported key without warning.
private const string MacOSAddCertificateToKeyChainCommandLine = "security";
private static readonly string s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import \"{0}\" -k \"" + s_macOSUserKeychain + "\" -t cert -f pkcs12 -P {1} -A";
// Remove a certificate from the admin trust settings. We no longer add certificates to the
// admin trust settings, but need this for cleaning up certs generated by pre-.NET 7 versions
// of this tool that used to create trust settings in the system keychain.
// Note: This operation will require user authentication.
private const string MacOSUntrustLegacyCertificateCommandLine = "sudo";
private const string MacOSUntrustLegacyCertificateCommandLineArguments = "security remove-trusted-cert -d \"{0}\"";
// Find all matching certificates on the keychain {1} that have the name {0} and print
// print their SHA-256 and SHA-1 hashes.
private const string MacOSFindCertificateOnKeychainCommandLine = "security";
private const string MacOSFindCertificateOnKeychainCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p \"{1}\"";
// Format used by the tool when printing SHA-1 hashes.
private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)";
public const string InvalidCertificateState =
"The ASP.NET Core developer certificate is in an invalid state. " +
"To fix this issue, run 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' " +
"to remove all existing ASP.NET Core development certificates " +
"and create a new untrusted developer certificate. " +
"Use 'dotnet dev-certs https --trust' to trust the new certificate.";
public MacOSCertificateManager(ILogger logger) : base(logger)
{
}
internal MacOSCertificateManager(string subject, int version)
: base(subject, version)
{
}
protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate)
{
// Populate the Aspire cache even when the certificate is already trusted so
// `aspire certs trust` can prewarm the cache without reapplying trust.
WriteAspireCacheFromDiskPfx(GetCertificateFilePath(publicCertificate), publicCertificate);
var oldTrustLevel = GetTrustLevel(publicCertificate);
if (oldTrustLevel != TrustLevel.None)
{
Debug.Assert(oldTrustLevel == TrustLevel.Full); // Mac trust is all or nothing
Log.MacOSCertificateAlreadyTrusted();
return oldTrustLevel;
}
var tmpFile = Path.GetTempFileName();
try
{
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
if (Log.IsEnabled())
{
Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}");
}
var processResult = CertificateProcessRunner.Run(
new ProcessStartInfo(MacOSTrustCertificateCommandLine, s_macOSTrustCertificateCommandLineArguments + tmpFile));
if (processResult.ExitCode != 0)
{
Log.MacOSTrustCommandError(processResult.ExitCode);
throw new InvalidOperationException("There was an error trusting the certificate.");
}
Log.MacOSTrustCommandEnd();
}
finally
{
try
{
File.Delete(tmpFile);
}
catch
{
// We don't care if we can't delete the temp file.
}
}
return TrustLevel.Full;
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate)
{
return File.Exists(GetCertificateFilePath(candidate)) ?
new CheckCertificateStateResult(true, null) :
new CheckCertificateStateResult(false, InvalidCertificateState);
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
try
{
var onDiskPfxPath = GetCertificateFilePath(candidate);
// The .aspnet PFX write needs to use the keychain-backed cert since
// it's the authoritative source being corrected.
ExportCertificate(candidate, onDiskPfxPath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
// For the Aspire cache, load from the on-disk PFX we just wrote to avoid
// a second keychain access prompt.
WriteAspireCacheFromDiskPfx(onDiskPfxPath, candidate);
}
catch (Exception ex)
{
Log.MacOSAddCertificateToUserProfileDirError(candidate.Thumbprint, ex.Message);
}
}
// Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy.
public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
{
var tmpFile = Path.GetTempFileName();
try
{
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
var checkTrustProcessResult = CertificateProcessRunner.Run(new ProcessStartInfo(
MacOSVerifyCertificateCommandLine,
string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile))
{
RedirectStandardOutput = true,
// Do this to avoid showing output to the console when the cert is not trusted. It is trivial to export
// the cert and replicate the command to see details.
RedirectStandardError = true,
});
return checkTrustProcessResult.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None;
}
finally
{
File.Delete(tmpFile);
}
}
protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
if (IsCertOnKeychain(MacOSSystemKeychain, certificate))
{
// Pre-.NET 7 versions of this tool used to store certs and trust settings on the
// system keychain. Check if that's the case for this cert, and if so, remove the
// trust rule and the cert from the system keychain.
try
{
RemoveAdminTrustRule(certificate);
RemoveCertificateFromKeychain(MacOSSystemKeychain, certificate);
}
catch
{
}
}
RemoveCertificateFromUserStoreCore(certificate);
}
// Remove the certificate from the admin trust settings.
private void RemoveAdminTrustRule(X509Certificate2 certificate)
{
Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate));
var certificatePath = Path.GetTempFileName();
try
{
var certBytes = certificate.Export(X509ContentType.Cert);
File.WriteAllBytes(certificatePath, certBytes);
var processInfo = new ProcessStartInfo(
MacOSUntrustLegacyCertificateCommandLine,
string.Format(
CultureInfo.InvariantCulture,
MacOSUntrustLegacyCertificateCommandLineArguments,
certificatePath
));
var processResult = CertificateProcessRunner.Run(processInfo);
if (processResult.ExitCode != 0)
{
Log.MacOSRemoveCertificateTrustRuleError(processResult.ExitCode);
}
Log.MacOSRemoveCertificateTrustRuleEnd();
}
finally
{
try
{
File.Delete(certificatePath);
}
catch
{
// We don't care if we can't delete the temp file.
}
}
}
private void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate)
{
var processInfo = new ProcessStartInfo(
MacOSDeleteCertificateCommandLine,
string.Format(
CultureInfo.InvariantCulture,
MacOSDeleteCertificateCommandLineArgumentsFormat,
certificate.Thumbprint.ToUpperInvariant(),
keychain
))
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
if (Log.IsEnabled())
{
Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate));
}
var processResult = CertificateProcessRunner.RunAndCaptureText(processInfo);
if (processResult.ExitCode != 0)
{
Log.MacOSRemoveCertificateFromKeyChainError(processResult.ExitCode);
throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'.
{processResult.StandardOutput}{processResult.StandardError}");
}
Log.MacOSRemoveCertificateFromKeyChainEnd();
}
private static bool IsCertOnKeychain(string keychain, X509Certificate2 certificate)
{
var maxRegexTimeout = TimeSpan.FromMinutes(1);
const string CertificateSubjectRegex = "CN=(.*[^,]+).*";
var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, maxRegexTimeout);
if (!subjectMatch.Success)
{
throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'.");
}
var subject = subjectMatch.Groups[1].Value;
// Run the find-certificate command, and look for the cert's hash in the output
using var findCertificateProcess = Process.Start(new ProcessStartInfo(
MacOSFindCertificateOnKeychainCommandLine,
string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateOnKeychainCommandLineArgumentsFormat, subject, keychain))
{
RedirectStandardOutput = true
});
var output = findCertificateProcess!.StandardOutput.ReadToEnd();
findCertificateProcess.WaitForExit();
var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, maxRegexTimeout);
var hashes = matches.OfType<Match>().Select(m => m.Groups[1].Value).ToList();
return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal));
}
// We don't have a good way of checking on the underlying implementation if it is exportable, so just return true.
internal override bool IsExportable(X509Certificate2 c) => true;
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
{
SaveCertificateToUserKeychain(certificate);
try
{
if (Log.IsEnabled())
{
Log.MacOSAddCertificateToUserProfileDirStart(s_macOSUserKeychain, GetDescription(certificate));
}
ExportCertificate(certificate, GetCertificateFilePath(certificate), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
var aspireLookup = CertificateHelpers.GetAspireCertificateHash(certificate);
ExportCertificate(certificate, Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.pfx"), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
}
catch (Exception ex)
{
Log.MacOSAddCertificateToUserProfileDirError(certificate.Thumbprint, ex.Message);
}
Log.MacOSAddCertificateToKeyChainEnd();
Log.MacOSAddCertificateToUserProfileDirEnd();
return certificate;
}
private void SaveCertificateToUserKeychain(X509Certificate2 certificate)
{
var passwordBytes = new byte[48];
RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]);
var password = Convert.ToBase64String(passwordBytes, 0, 36);
var certBytes = certificate.Export(X509ContentType.Pfx, password);
var certificatePath = Path.GetTempFileName();
File.WriteAllBytes(certificatePath, certBytes);
var processInfo = new ProcessStartInfo(
MacOSAddCertificateToKeyChainCommandLine,
string.Format(CultureInfo.InvariantCulture, s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password))
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
if (Log.IsEnabled())
{
Log.MacOSAddCertificateToKeyChainStart(s_macOSUserKeychain, GetDescription(certificate));
}
var processResult = CertificateProcessRunner.RunAndCaptureText(processInfo);
if (processResult.ExitCode != 0)
{
var output = processResult.StandardOutput + processResult.StandardError;
Log.MacOSAddCertificateToKeyChainError(processResult.ExitCode, output);
throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?");
}
Log.MacOSAddCertificateToKeyChainEnd();
}
private static string GetCertificateFilePath(X509Certificate2 certificate) =>
Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx");
/// <summary>
/// Writes Aspire hosting cache entries (PFX and PEM key) by loading the certificate from the
/// on-disk PFX at <paramref name="onDiskPfxPath"/> to avoid triggering a macOS Keychain
/// access prompt. If the on-disk PFX does not yet exist (for example, when correcting the
/// state of a pre-.NET 7 keychain-only certificate), it is exported first so the Aspire
/// cache can always be warmed alongside it. This is a best-effort operation; failures are
/// silently ignored so the app host can fall back to caching at startup.
/// </summary>
private void WriteAspireCacheFromDiskPfx(string onDiskPfxPath, X509Certificate2 certificate)
{
try
{
if (!File.Exists(onDiskPfxPath))
{
// The .aspnet PFX is the preferred source because loading from it avoids a
// second keychain prompt. If it's missing, export it first (this is a
// private-key export from the keychain, so it may prompt once) so both the
// .aspnet and Aspire caches are populated together.
ExportCertificate(certificate, onDiskPfxPath, includePrivateKey: true, password: null, CertificateKeyExportFormat.Pfx);
}
using var diskCert = X509CertificateLoader.LoadPkcs12FromFile(onDiskPfxPath, password: null, X509KeyStorageFlags.Exportable);
var aspireLookup = CertificateHelpers.GetAspireCertificateHash(certificate);
CreateDirectoryWithPermissions(s_aspireDevCertsCacheDirectory);
ExportCertificate(diskCert, Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.pfx"), includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
// Write PEM key cache — must match the format produced by DeveloperCertificateService.ExportKeyPem
using var key = diskCert.GetRSAPrivateKey();
if (key is not null)
{
var keyBytes = key.ExportPkcs8PrivateKey();
var pem = PemEncoding.Write("PRIVATE KEY", keyBytes);
Array.Clear(keyBytes, 0, keyBytes.Length);
var keyPath = Path.Combine(s_aspireDevCertsCacheDirectory, $"{aspireLookup}.key");
File.WriteAllBytes(keyPath, Encoding.UTF8.GetBytes(pem));
Array.Clear(pem, 0, pem.Length);
}
}
catch
{
// Best effort — the app host will fall back to accessing the keychain directly.
}
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
}
protected override void PopulateCertificatesFromStore(X509Store store, List<X509Certificate2> certificates, bool requireExportable)
{
if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(s_macOSUserHttpsCertificateLocation))
{
var certsFromDisk = GetCertsFromDisk();
var certsFromStore = new List<X509Certificate2>();
base.PopulateCertificatesFromStore(store, certsFromStore, requireExportable);
// Certs created by pre-.NET 7.
var onlyOnKeychain = certsFromStore.Except(certsFromDisk, ThumbprintComparer.Instance);
// Certs created (or "upgraded") by .NET 7+.
// .NET 7+ installs the certificate on disk as well as on the user keychain (for backwards
// compatibility with pre-.NET 7).
// Note that if we require exportable certs, the actual certs we populate need to be the ones
// from the store location, and not the version from disk. If we don't require exportability,
// we favor the version of the cert that's on disk (avoiding unnecessary keychain access
// prompts). Intersect compares with the specified comparer and returns the matching elements
// from the first set.
var onDiskAndKeychain = requireExportable ? certsFromStore.Intersect(certsFromDisk, ThumbprintComparer.Instance)
: certsFromDisk.Intersect(certsFromStore, ThumbprintComparer.Instance);
// The only times we can find a certificate on the keychain and a certificate on keychain+disk
// are when the certificate on disk and keychain has expired and a pre-.NET 7 SDK has been
// used to create a new certificate, or when a pre-.NET 7 certificate has expired and .NET 7+
// has been used to create a new certificate. In both cases, the caller filters the invalid
// certificates out, so only the valid certificate is selected.
certificates.AddRange(onlyOnKeychain);
certificates.AddRange(onDiskAndKeychain);
}
else
{
base.PopulateCertificatesFromStore(store, certificates, requireExportable);
}
}
private sealed class ThumbprintComparer : IEqualityComparer<X509Certificate2>
{
public static readonly IEqualityComparer<X509Certificate2> Instance = new ThumbprintComparer();
#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes).
bool IEqualityComparer<X509Certificate2>.Equals(X509Certificate2 x, X509Certificate2 y) =>
EqualityComparer<string>.Default.Equals(x?.Thumbprint, y?.Thumbprint);
#pragma warning restore CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes).
int IEqualityComparer<X509Certificate2>.GetHashCode([DisallowNull] X509Certificate2 obj) =>
EqualityComparer<string>.Default.GetHashCode(obj.Thumbprint);
}
private ICollection<X509Certificate2> GetCertsFromDisk()
{
var certsFromDisk = new List<X509Certificate2>();
if (!Directory.Exists(s_macOSUserHttpsCertificateLocation))
{
Log.MacOSDiskStoreDoesNotExist();
}
else
{
var certificateFiles = Directory.EnumerateFiles(s_macOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx");
foreach (var file in certificateFiles)
{
try
{
var certificate = X509CertificateLoader.LoadPkcs12FromFile(file, password: null);
certsFromDisk.Add(certificate);
}
catch (Exception)
{
Log.MacOSFileIsNotAValidCertificate(file);
throw;
}
}
}
return certsFromDisk;
}
protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate)
{
try
{
var certificatePath = GetCertificateFilePath(certificate);
if (File.Exists(certificatePath))
{
File.Delete(certificatePath);
}
}
catch (Exception ex)
{
Log.MacOSRemoveCertificateFromUserProfileDirError(certificate.Thumbprint, ex.Message);
}
if (IsCertOnKeychain(s_macOSUserKeychain, certificate))
{
RemoveCertificateFromKeychain(s_macOSUserKeychain, certificate);
}
}
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
}
}