-
由 Adeel Mujahid 创作于由 Adeel Mujahid 创作于
MacOSCertificateManager.cs 13.23 KiB
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
namespace Microsoft.AspNetCore.Certificates.Generation;
internal sealed class MacOSCertificateManager : CertificateManager
{
private const string CertificateSubjectRegex = "CN=(.*[^,]+).*";
private static readonly string MacOSUserKeyChain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain";
private const string MacOSFindCertificateCommandLine = "security";
private const string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain;
private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)";
private const string MacOSRemoveCertificateTrustCommandLine = "sudo";
private const string MacOSRemoveCertificateTrustCommandLineArgumentsFormat = "security remove-trusted-cert -d {0}";
private const string MacOSDeleteCertificateCommandLine = "sudo";
private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}";
private const string MacOSTrustCertificateCommandLine = "sudo";
private const string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " ";
private const string MacOSAddCertificateToKeyChainCommandLine = "security";
private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import {0} -k " + MacOSUserKeyChain + " -t cert -f pkcs12 -P {1} -A";
public const string InvalidCertificateState = "The ASP.NET Core developer certificate is in an invalid state. " +
"To fix this issue, run the following commands '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. " +
"On macOS or Windows, use 'dotnet dev-certs https --trust' to trust the new certificate.";
public const string KeyNotAccessibleWithoutUserInteraction =
"The application is trying to access the ASP.NET Core developer certificate key. " +
"A prompt might appear to ask for permission to access the key. " +
"When that happens, select 'Always Allow' to grant 'dotnet' access to the certificate key in the future.";
private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1);
public MacOSCertificateManager()
{
}
internal MacOSCertificateManager(string subject, int version)
: base(subject, version)
{
}
protected override void TrustCertificateCore(X509Certificate2 publicCertificate)
{
var tmpFile = Path.GetTempFileName();
try
{
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
if (Log.IsEnabled())
{
Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}");
}
using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile))
{
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSTrustCommandError(process.ExitCode);
throw new InvalidOperationException("There was an error trusting the certificate.");
}
}
Log.MacOSTrustCommandEnd();
}
finally
{
try
{
if (File.Exists(tmpFile))
{
File.Delete(tmpFile);
}
}
catch
{
// We don't care if we can't delete the temp file.
}
}
}
internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate, bool interactive)
{
var sentinelPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", $"certificates.{candidate.GetCertHashString(HashAlgorithmName.SHA256)}.sentinel");
if (!interactive && !File.Exists(sentinelPath))
{
return new CheckCertificateStateResult(false, KeyNotAccessibleWithoutUserInteraction);
}
// Tries to use the certificate key to validate it can't access it
try
{
using var rsa = candidate.GetRSAPrivateKey();
if (rsa == null)
{
return new CheckCertificateStateResult(false, InvalidCertificateState);
}
// Encrypting a random value is the ultimate test for a key validity.
// Windows and Mac OS both return HasPrivateKey = true if there is (or there has been) a private key associated
// with the certificate at some point.
var value = new byte[32];
RandomNumberGenerator.Fill(value);
rsa.Decrypt(rsa.Encrypt(value, RSAEncryptionPadding.Pkcs1), RSAEncryptionPadding.Pkcs1);
// If we were able to access the key, create a sentinel so that we don't have to show a prompt
// on every kestrel run.
if (Directory.Exists(Path.GetDirectoryName(sentinelPath)) && !File.Exists(sentinelPath))
{
File.WriteAllText(sentinelPath, "true");
}
// Being able to encrypt and decrypt a payload is the strongest guarantee that the key is valid.
return new CheckCertificateStateResult(true, null);
}
catch (Exception)
{
return new CheckCertificateStateResult(false, InvalidCertificateState);
}
}
internal override void CorrectCertificateState(X509Certificate2 candidate)
{
var status = CheckCertificateState(candidate, true);
if (!status.Success)
{
throw new InvalidOperationException(InvalidCertificateState);
}
}
public override bool IsTrusted(X509Certificate2 certificate)
{
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;
using var checkTrustProcess = Process.Start(new ProcessStartInfo(
MacOSFindCertificateCommandLine,
string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateCommandLineArgumentsFormat, subject))
{
RedirectStandardOutput = true
});
var output = checkTrustProcess!.StandardOutput.ReadToEnd();
checkTrustProcess.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));
}
protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
{
if (IsTrusted(certificate)) // On OSX this check just ensures its on the system keychain
{
// A trusted certificate in OSX is installed into the system keychain and
// as a "trust rule" applied to it.
// To remove the certificate we first need to remove the "trust rule" and then
// remove the certificate from the keychain.
// We don't care if we fail to remove the trust rule if
// for some reason the certificate became untrusted.
// Trying to remove the certificate from the keychain will fail if the certificate is
// trusted.
try
{
RemoveCertificateTrustRule(certificate);
}
catch
{
}
RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate);
}
else
{
Log.MacOSCertificateUntrusted(GetDescription(certificate));
}
}
private static void RemoveCertificateTrustRule(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(
MacOSRemoveCertificateTrustCommandLine,
string.Format(
CultureInfo.InvariantCulture,
MacOSRemoveCertificateTrustCommandLineArgumentsFormat,
certificatePath
));
using var process = Process.Start(processInfo);
process!.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode);
}
Log.MacOSRemoveCertificateTrustRuleEnd();
}
finally
{
try
{
if (File.Exists(certificatePath))
{
File.Delete(certificatePath);
}
}
catch
{
// We don't care about failing to do clean-up on a temp file.
}
}
}
private static 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));
}
using (var process = Process.Start(processInfo))
{
var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode);
throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'.
{output}");
}
}
Log.MacOSRemoveCertificateFromKeyChainEnd();
}
// We don't have a good way of checking on the underlying implementation if ti is exportable, so just return true.
protected override bool IsExportable(X509Certificate2 c) => true;
protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
{
// security import https.pfx -k $loginKeyChain -t cert -f pkcs12 -P password -A;
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,
MacOSAddCertificateToKeyChainCommandLineArgumentsFormat,
certificatePath,
password
))
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
if (Log.IsEnabled())
{
Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeyChain, GetDescription(certificate));
}
using (var process = Process.Start(processInfo))
{
var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Log.MacOSAddCertificateToKeyChainError(process.ExitCode);
throw new InvalidOperationException($@"There was an error importing the certificate into the user key chain '{certificate.Thumbprint}'.
{output}");
}
}
Log.MacOSAddCertificateToKeyChainEnd();
return certificate;
}
protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation)
{
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false);
}
}