Skip to content
代码片段 群组 项目
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);
    }
}