diff --git a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs index 14795a43465c..cc814808e0fe 100644 --- a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs +++ b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs @@ -35,7 +35,7 @@ private static string EnsureDevelopmentCertificates(string certificatePath, stri var manager = CertificateManager.Instance; var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); var certificateThumbprint = certificate.Thumbprint; - manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword); + manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx); return certificateThumbprint; } diff --git a/src/Shared/CertificateGeneration/CertificateExportFormat.cs b/src/Shared/CertificateGeneration/CertificateExportFormat.cs new file mode 100644 index 000000000000..00c4c334ccc9 --- /dev/null +++ b/src/Shared/CertificateGeneration/CertificateExportFormat.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Certificates.Generation +{ + internal enum CertificateKeyExportFormat + { + Pfx, + Pem, + } +} diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 5c0d8539762b..23ed9fb19e97 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -153,6 +153,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( bool trust = false, bool includePrivateKey = false, string password = null, + CertificateKeyExportFormat keyExportFormat = CertificateKeyExportFormat.Pfx, bool isInteractive = true) { var result = EnsureCertificateResult.Succeeded; @@ -170,6 +171,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( certificates = filteredCertificates; X509Certificate2 certificate = null; + var isNewCertificate = false; if (certificates.Any()) { certificate = certificates.First(); @@ -216,6 +218,7 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( try { Log.CreateDevelopmentCertificateStart(); + isNewCertificate = true; certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter); } catch (Exception e) @@ -260,13 +263,13 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( { try { - ExportCertificate(certificate, path, includePrivateKey, password); + ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat); } catch (Exception e) { Log.ExportCertificateError(e.ToString()); // We don't want to mask the original source of the error here. - result = result != EnsureCertificateResult.Succeeded || result != EnsureCertificateResult.ValidCertificatePresent ? + result = result != EnsureCertificateResult.Succeeded && result != EnsureCertificateResult.ValidCertificatePresent ? result : EnsureCertificateResult.ErrorExportingTheCertificate; @@ -292,9 +295,58 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( } } + DisposeCertificates(!isNewCertificate ? certificates : certificates.Append(certificate)); + return result; } + internal ImportCertificateResult ImportCertificate(string certificatePath, string password) + { + if (!File.Exists(certificatePath)) + { + Log.ImportCertificateMissingFile(certificatePath); + return ImportCertificateResult.CertificateFileMissing; + } + + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + if (certificates.Any()) + { + Log.ImportCertificateExistingCertificates(ToCertificateDescription(certificates)); + return ImportCertificateResult.ExistingCertificatesPresent; + } + + X509Certificate2 certificate; + try + { + Log.LoadCertificateStart(certificatePath); + certificate = new X509Certificate2(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + Log.LoadCertificateEnd(GetDescription(certificate)); + } + catch (Exception e) + { + Log.LoadCertificateError(e.ToString()); + return ImportCertificateResult.InvalidCertificate; + } + + if (!IsHttpsDevelopmentCertificate(certificate)) + { + Log.NoHttpsDevelopmentCertificate(GetDescription(certificate)); + return ImportCertificateResult.NoDevelopmentHttpsCertificate; + } + + try + { + SaveCertificate(certificate); + } + catch (Exception e) + { + Log.SaveCertificateInStoreError(e.ToString()); + return ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + } + + return ImportCertificateResult.Succeeded; + } + public void CleanupHttpsCertificates() { // On OS X we don't have a good way to manage trusted certificates in the system keychain @@ -329,7 +381,7 @@ public void CleanupHttpsCertificates() protected abstract IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation); - internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password) + internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password, CertificateKeyExportFormat format) { Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey); if (includePrivateKey && password == null) @@ -345,15 +397,69 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool } byte[] bytes; + byte[] keyBytes; + byte[] pemEnvelope = null; + RSA key = null; + try { - bytes = includePrivateKey ? certificate.Export(X509ContentType.Pkcs12, password) : certificate.Export(X509ContentType.Cert); + if (includePrivateKey) + { + switch (format) + { + case CertificateKeyExportFormat.Pfx: + bytes = certificate.Export(X509ContentType.Pkcs12, password); + break; + case CertificateKeyExportFormat.Pem: + key = certificate.GetRSAPrivateKey(); + + char[] pem; + if (password != null) + { + keyBytes = key.ExportEncryptedPkcs8PrivateKey(password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100000)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + else + { + // Export the key first to an encrypted PEM to avoid issues with System.Security.Cryptography.Cng indicating that the operation is not supported. + // This is likely by design to avoid exporting the key by mistake. + // To bypass it, we export the certificate to pem temporarily and then we import it and export it as unprotected PEM. + keyBytes = key.ExportEncryptedPkcs8PrivateKey("", new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + key.Dispose(); + key = RSA.Create(); + key.ImportFromEncryptedPem(pem, ""); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = key.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + + bytes = certificate.Export(X509ContentType.Cert); + break; + default: + throw new InvalidOperationException("Unknown format."); + } + } + else + { + bytes = certificate.Export(X509ContentType.Cert); + } } catch (Exception e) { Log.ExportCertificateError(e.ToString()); throw; } + finally + { + key?.Dispose(); + } try { @@ -369,6 +475,25 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool { Array.Clear(bytes, 0, bytes.Length); } + + if (includePrivateKey && format == CertificateKeyExportFormat.Pem) + { + try + { + var keyPath = Path.ChangeExtension(path, ".key"); + Log.WritePemKeyToDisk(keyPath); + File.WriteAllBytes(keyPath, pemEnvelope); + } + catch (Exception ex) + { + Log.WritePemKeyToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(pemEnvelope, 0, pemEnvelope.Length); + } + } } internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter) @@ -496,7 +621,7 @@ internal X509Certificate2 CreateSelfSignedCertificate( DateTimeOffset notBefore, DateTimeOffset notAfter) { - var key = CreateKeyMaterial(RSAMinimumKeySizeInBits); + using var key = CreateKeyMaterial(RSAMinimumKeySizeInBits); var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); foreach (var extension in extensions) @@ -745,6 +870,31 @@ public void ExportCertificateStart(string certificate, string path, bool include [Event(56, Level = EventLevel.Error)] internal void MacOSAddCertificateToKeyChainError(int exitCode) => WriteEvent(56, $"An error has ocurred while importing the certificate to the keychain: {exitCode}."); + + + [Event(57, Level = EventLevel.Verbose)] + public void WritePemKeyToDisk(string path) => WriteEvent(57, $"Writing the certificate to: {path}."); + + [Event(58, Level = EventLevel.Error)] + public void WritePemKeyToDiskError(string ex) => WriteEvent(58, $"An error has ocurred while writing the certificate to disk: {ex}."); + + [Event(59, Level = EventLevel.Error)] + internal void ImportCertificateMissingFile(string certificatePath) => WriteEvent(59, $"The file '{certificatePath}' does not exist."); + + [Event(60, Level = EventLevel.Error)] + internal void ImportCertificateExistingCertificates(string certificateDescription) => WriteEvent(60, $"One or more HTTPS certificates exist '{certificateDescription}'."); + + [Event(61, Level = EventLevel.Verbose)] + internal void LoadCertificateStart(string certificatePath) => WriteEvent(61, $"Loading certificate from path '{certificatePath}'."); + + [Event(62, Level = EventLevel.Verbose)] + internal void LoadCertificateEnd(string description) => WriteEvent(62, $"The certificate '{description}' has been loaded successfully."); + + [Event(63, Level = EventLevel.Error)] + internal void LoadCertificateError(string ex) => WriteEvent(63, $"An error has ocurred while loading the certificate from disk: {ex}."); + + [Event(64, Level = EventLevel.Error)] + internal void NoHttpsDevelopmentCertificate(string description) => WriteEvent(64, $"The provided certificate '{description}' is not a valid ASP.NET Core HTTPS development certificate."); } internal class UserCancelledTrustException : Exception diff --git a/src/Shared/CertificateGeneration/ImportCertificateResult.cs b/src/Shared/CertificateGeneration/ImportCertificateResult.cs new file mode 100644 index 000000000000..2738417a620c --- /dev/null +++ b/src/Shared/CertificateGeneration/ImportCertificateResult.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Certificates.Generation +{ + internal enum ImportCertificateResult + { + Succeeded = 1, + CertificateFileMissing, + InvalidCertificate, + NoDevelopmentHttpsCertificate, + ExistingCertificatesPresent, + ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, + } +} + diff --git a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs index b11c9ac9149a..46e48eb57d4f 100644 --- a/src/Shared/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Shared/CertificateGeneration/MacOSCertificateManager.cs @@ -54,7 +54,7 @@ protected override void TrustCertificateCore(X509Certificate2 publicCertificate) var tmpFile = Path.GetTempFileName(); try { - ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null); + ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}"); using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) { @@ -94,7 +94,7 @@ internal override CheckCertificateStateResult CheckCertificateState(X509Certific // Tries to use the certificate key to validate it can't access it try { - var rsa = candidate.GetRSAPrivateKey(); + using var rsa = candidate.GetRSAPrivateKey(); if (rsa == null) { return new CheckCertificateStateResult(false, InvalidCertificateState); diff --git a/src/Shared/CertificateGeneration/UnixCertificateManager.cs b/src/Shared/CertificateGeneration/UnixCertificateManager.cs index a66cebc22841..7b3dbc055add 100644 --- a/src/Shared/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Shared/CertificateGeneration/UnixCertificateManager.cs @@ -20,6 +20,7 @@ internal UnixCertificateManager(string subject, int version) protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate) { var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); Array.Clear(export, 0, export.Length); diff --git a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs index 82fc96177827..cbeed665d24f 100644 --- a/src/Shared/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Shared/CertificateGeneration/WindowsCertificateManager.cs @@ -26,9 +26,10 @@ protected override bool IsExportable(X509Certificate2 c) // For the first run experience we don't need to know if the certificate can be exported. return true; #else - return (c.GetRSAPrivateKey() is RSACryptoServiceProvider rsaPrivateKey && + using var key = c.GetRSAPrivateKey(); + return (key is RSACryptoServiceProvider rsaPrivateKey && rsaPrivateKey.CspKeyContainerInfo.Exportable) || - (c.GetRSAPrivateKey() is RSACng cngPrivateKey && + (key is RSACng cngPrivateKey && cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport); #endif } @@ -49,6 +50,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi // On non OSX systems we need to export the certificate and import it so that the transient // key that we generated gets persisted. var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); Array.Clear(export, 0, export.Length); certificate.FriendlyName = AspNetHttpsOidFriendlyName; @@ -65,7 +67,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi protected override void TrustCertificateCore(X509Certificate2 certificate) { - var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); + using var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); publicCertificate.FriendlyName = certificate.FriendlyName; diff --git a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs index f0c53bfb5c0f..ae29ab17621b 100644 --- a/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs +++ b/src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using Microsoft.AspNetCore.Testing; using Xunit; using Xunit.Abstractions; @@ -155,6 +156,138 @@ public void EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAn Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); } + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")] + public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat() + { + // Arrange + var message = "plaintext"; + const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + var certificatePassword = Guid.NewGuid().ToString(); + + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + + // Act + var result = _manager + .EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, keyExportFormat: CertificateKeyExportFormat.Pem, isInteractive: false); + + // Assert + Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result); + Assert.True(File.Exists(CertificateName)); + + var key = RSA.Create(); + key.ImportFromEncryptedPem(File.ReadAllText(Path.ChangeExtension(CertificateName, "key")), certificatePassword); + var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName)); + exportedCertificate = exportedCertificate.CopyWithPrivateKey(key); + Assert.NotNull(exportedCertificate); + Assert.True(exportedCertificate.HasPrivateKey); + + Assert.Equal("plaintext", Encoding.ASCII.GetString(exportedCertificate.GetRSAPrivateKey().Decrypt(exportedCertificate.GetRSAPrivateKey().Encrypt(Encoding.ASCII.GetBytes(message), RSAEncryptionPadding.OaepSHA256), RSAEncryptionPadding.OaepSHA256))); + Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); + } + + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")] + public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx() + { + // Arrange + const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + var certificatePassword = Guid.NewGuid().ToString(); + + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + + _manager + .EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, isInteractive: false); + + _manager.CleanupHttpsCertificates(); + + // Act + var result = _manager.ImportCertificate(CertificateName, certificatePassword); + + // Assert + Assert.Equal(ImportCertificateResult.Succeeded, result); + var importedCertificate = Assert.Single(_manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false)); + + Assert.Equal(httpsCertificate.GetCertHashString(), importedCertificate.GetCertHashString()); + } + + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")] + public void EnsureCreateHttpsCertificate_CanImport_ExportedPfx_FailsIfThereAreCertificatesPresent() + { + // Arrange + const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + var certificatePassword = Guid.NewGuid().ToString(); + + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + + _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, isInteractive: false); + + // Act + var result = _manager.ImportCertificate(CertificateName, certificatePassword); + + // Assert + Assert.Equal(ImportCertificateResult.ExistingCertificatesPresent, result); + } + + [ConditionalFact] + [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")] + public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutPassword() + { + // Arrange + var message = "plaintext"; + const string CertificateName = nameof(EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; + _fixture.CleanupCertificates(); + + var now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false); + Output.WriteLine(creation.ToString()); + ListCertificates(); + + var httpsCertificate = _manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); + // Act + var result = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: null, keyExportFormat: CertificateKeyExportFormat.Pem, isInteractive: false); + + // Assert + Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result); + Assert.True(File.Exists(CertificateName)); + + var key = RSA.Create(); + key.ImportFromPem(File.ReadAllText(Path.ChangeExtension(CertificateName, "key"))); + var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName)); + exportedCertificate = exportedCertificate.CopyWithPrivateKey(key); + Assert.NotNull(exportedCertificate); + Assert.True(exportedCertificate.HasPrivateKey); + + Assert.Equal("plaintext", Encoding.ASCII.GetString(exportedCertificate.GetRSAPrivateKey().Decrypt(exportedCertificate.GetRSAPrivateKey().Encrypt(Encoding.ASCII.GetBytes(message), RSAEncryptionPadding.OaepSHA256), RSAEncryptionPadding.OaepSHA256))); + Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); + } + [Fact] public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsIncorrect() { diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index c43a438a8bb4..a53378469fe5 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -26,6 +26,21 @@ internal class Program private const int ErrorCertificateNotTrusted = 7; private const int ErrorCleaningUpCertificates = 8; private const int InvalidCertificateState = 9; + private const int InvalidKeyExportFormat = 10; + private const int ErrorImportingCertificate = 11; + private const int MissingCertificateFile = 12; + private const int FailedToLoadCertificate = 13; + private const int NoDevelopmentHttpsCertificate = 14; + private const int ExistingCertificatesPresent = 15; + + private const string InvalidUsageErrorMessage = @"Incompatible set of flags. Sample usages +'dotnet dev-certs https' +'dotnet dev-certs https --clean' +'dotnet dev-certs https --clean --import ./certificate.pfx -p password' +'dotnet dev-certs https --check --trust' +'dotnet dev-certs https -ep ./certificate.pfx -p password --trust' +'dotnet dev-certs https -ep ./certificate.crt --trust --key-format Pem' +'dotnet dev-certs https -ep ./certificate.crt -p password --trust --key-format Pem'"; public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365); @@ -33,7 +48,6 @@ public static int Main(string[] args) { if (args.Contains("--debug")) { - // This is so that we can attach `dotnet trace` for debug purposes. Console.WriteLine("Press any key to continue..."); _ = Console.ReadKey(); var newArgs = new List(args); @@ -55,9 +69,14 @@ public static int Main(string[] args) CommandOptionType.SingleValue); var password = c.Option("-p|--password", - "Password to use when exporting the certificate with the private key into a pfx file", + "Password to use when exporting the certificate with the private key into a pfx file or to encrypt the Pem exported key", CommandOptionType.SingleValue); + // We want to force generating a key without a password to not be an accident. + var noPassword = c.Option("-np|--no-password", + "Explicitly request that you don't use a password for the key when exporting a certificate to a PEM format", + CommandOptionType.NoValue); + var check = c.Option( "-c|--check", "Check for the existence of the certificate but do not perform any action", @@ -68,6 +87,16 @@ public static int Main(string[] args) "Cleans all HTTPS development certificates from the machine.", CommandOptionType.NoValue); + var import = c.Option( + "-i|--import", + "Imports the provided HTTPS development certificate into the machine. All other HTTPS developer certificates will be cleared out", + CommandOptionType.SingleValue); + + var keyFormat = c.Option( + "--key-format", + "Export the certificate key in the given format. Valid values are Pfx and Pem. Pfx is the default.", + CommandOptionType.SingleValue); + CommandOption trust = null; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -89,14 +118,46 @@ public static int Main(string[] args) c.OnExecute(() => { var reporter = new ConsoleReporter(PhysicalConsole.Singleton, verbose.HasValue(), quiet.HasValue()); - if ((clean.HasValue() && (exportPath.HasValue() || password.HasValue() || trust?.HasValue() == true)) || - (check.HasValue() && (exportPath.HasValue() || password.HasValue() || clean.HasValue()))) + + if (clean.HasValue()) { - reporter.Error(@"Incompatible set of flags. Sample usages -'dotnet dev-certs https' -'dotnet dev-certs https --clean' -'dotnet dev-certs https --check --trust' -'dotnet dev-certs https -ep ./certificate.pfx -p password --trust'"); + if (exportPath.HasValue() || trust?.HasValue() == true || keyFormat.HasValue() || noPassword.HasValue() || check.HasValue() || + (!import.HasValue() && password.HasValue()) || + (import.HasValue() && !password.HasValue())) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } + } + + if (check.HasValue()) + { + if (exportPath.HasValue() || password.HasValue() || noPassword.HasValue() || clean.HasValue() || keyFormat.HasValue() || import.HasValue()) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } + } + + if (!clean.HasValue() && !check.HasValue()) + { + if (password.HasValue() && noPassword.HasValue()) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } + + if (noPassword.HasValue() && !(keyFormat.HasValue() && string.Equals(keyFormat.Value(), "PEM", StringComparison.OrdinalIgnoreCase))) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } + + if (import.HasValue()) + { + reporter.Error(InvalidUsageErrorMessage); + return CriticalError; + } } if (check.HasValue()) @@ -106,10 +167,16 @@ public static int Main(string[] args) if (clean.HasValue()) { - return CleanHttpsCertificates(reporter); + var clean = CleanHttpsCertificates(reporter); + if (clean != Success || !import.HasValue()) + { + return clean; + } + + return ImportCertificate(import, password, reporter); } - return EnsureHttpsCertificate(exportPath, password, trust, reporter); + return EnsureHttpsCertificate(exportPath, password, noPassword, trust, keyFormat, reporter); }); }); @@ -129,6 +196,44 @@ public static int Main(string[] args) } } + private static int ImportCertificate(CommandOption import, CommandOption password, ConsoleReporter reporter) + { + var manager = CertificateManager.Instance; + try + { + var result = manager.ImportCertificate(import.Value(), password.Value()); + switch (result) + { + case ImportCertificateResult.Succeeded: + reporter.Output("The certificate was successfully imported."); + break; + case ImportCertificateResult.CertificateFileMissing: + reporter.Error($"The certificate file '{import.Value()}' does not exist."); + return MissingCertificateFile; + case ImportCertificateResult.InvalidCertificate: + reporter.Error($"The provided certificate file '{import.Value()}' is not a valid PFX file or the password is incorrect."); + return FailedToLoadCertificate; + case ImportCertificateResult.NoDevelopmentHttpsCertificate: + reporter.Error($"The certificate at '{import.Value()}' is not a valid ASP.NET Core HTTPS development certificate."); + return NoDevelopmentHttpsCertificate; + case ImportCertificateResult.ExistingCertificatesPresent: + reporter.Error($"There are one or more ASP.NET Core HTTPS development certificates present in the environment. Remove them before importing the given certificate."); + return ExistingCertificatesPresent; + case ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore: + reporter.Error("There was an error saving the HTTPS developer certificate to the current user personal certificate store."); + return ErrorSavingTheCertificate; + default: + break; + } + } + catch (Exception) + { + return ErrorImportingCertificate; + } + + return Success; + } + private static int CleanHttpsCertificates(IReporter reporter) { var manager = CertificateManager.Instance; @@ -204,7 +309,7 @@ private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter return Success; } - private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption trust, IReporter reporter) + private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption noPassword, CommandOption trust, CommandOption keyFormat, IReporter reporter) { var now = DateTimeOffset.Now; var manager = CertificateManager.Instance; @@ -242,13 +347,21 @@ private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOptio "if the certificate was not previously trusted. Click yes on the prompt to trust the certificate."); } + var format = CertificateKeyExportFormat.Pfx; + if (keyFormat.HasValue() && !Enum.TryParse(keyFormat.Value(), ignoreCase: true, out format)) + { + reporter.Error($"Unknown key format '{keyFormat.Value()}'."); + return InvalidKeyExportFormat; + } + var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate( now, now.Add(HttpsCertificateValidity), exportPath.Value(), trust == null ? false : trust.HasValue(), - password.HasValue(), - password.Value()); + password.HasValue() || (noPassword.HasValue() && format == CertificateKeyExportFormat.Pem), + password.Value(), + keyFormat.HasValue() ? format : CertificateKeyExportFormat.Pfx); switch (result) {