diff --git a/DotNetTools.sln b/DotNetTools.sln index e788eb2226..8af718e399 100644 --- a/DotNetTools.sln +++ b/DotNetTools.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26210.0 +VisualStudioVersion = 15.0.26510.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{66517987-2A5A-4330-B130-207039378FD4}" EndProject @@ -24,6 +24,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Watcher.To EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.SqlConfig.Tools", "src\Microsoft.Extensions.Caching.SqlConfig.Tools\Microsoft.Extensions.Caching.SqlConfig.Tools.csproj", "{53F3B53D-303A-4DAA-9C38-4F55195FA5B9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.CertificateGeneration.Task", "src\Microsoft.AspNetCore.CertificateGeneration.Task\Microsoft.AspNetCore.CertificateGeneration.Task.csproj", "{7B293291-26F4-47F0-9C2F-E396F35A4280}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.CertificateGeneration.Task.Tests", "test\Microsoft.AspNetcore.CertificateGeneration.Task.Tests\Microsoft.AspNetCore.CertificateGeneration.Task.Tests.csproj", "{3A7EF01A-073B-4123-850D-DFA4701EBE5B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +58,14 @@ Global {53F3B53D-303A-4DAA-9C38-4F55195FA5B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {53F3B53D-303A-4DAA-9C38-4F55195FA5B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {53F3B53D-303A-4DAA-9C38-4F55195FA5B9}.Release|Any CPU.Build.0 = Release|Any CPU + {7B293291-26F4-47F0-9C2F-E396F35A4280}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B293291-26F4-47F0-9C2F-E396F35A4280}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B293291-26F4-47F0-9C2F-E396F35A4280}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B293291-26F4-47F0-9C2F-E396F35A4280}.Release|Any CPU.Build.0 = Release|Any CPU + {3A7EF01A-073B-4123-850D-DFA4701EBE5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A7EF01A-073B-4123-850D-DFA4701EBE5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A7EF01A-073B-4123-850D-DFA4701EBE5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A7EF01A-073B-4123-850D-DFA4701EBE5B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -65,5 +77,7 @@ Global {7B331122-83B1-4F08-A119-DC846959844C} = {F5B382BC-258F-46E1-AC3D-10E5CCD55134} {8A2E6961-6B12-4A8E-8215-3E7301D52EAC} = {F5B382BC-258F-46E1-AC3D-10E5CCD55134} {53F3B53D-303A-4DAA-9C38-4F55195FA5B9} = {66517987-2A5A-4330-B130-207039378FD4} + {7B293291-26F4-47F0-9C2F-E396F35A4280} = {66517987-2A5A-4330-B130-207039378FD4} + {3A7EF01A-073B-4123-850D-DFA4701EBE5B} = {F5B382BC-258F-46E1-AC3D-10E5CCD55134} EndGlobalSection EndGlobal diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index 92dad6ebe4..214ca77a9c 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -18,6 +18,13 @@ "packageTypes": [ "DotnetCliTool" ] + }, + "Microsoft.AspNetCore.CertificateGeneration.Task": { + "exclusions":{ + "BUILD_ITEMS_FRAMEWORK": { + "*": "This is an MSBuild task intended to run through dotnet msbuild /t:Target independently of whether your project targets full framework or .net core." + } + } } } }, diff --git a/build/dependencies.props b/build/dependencies.props index afd45bf3ab..a6c3217f9f 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -11,6 +11,7 @@ 4.3.0 2.1.0-* + 15.1.1012 $(BundledNETStandardPackageVersion) 15.3.0-* 2.3.0-beta2-* diff --git a/src/Microsoft.AspNetCore.CertificateGeneration.Task/CertificateManager.cs b/src/Microsoft.AspNetCore.CertificateGeneration.Task/CertificateManager.cs new file mode 100644 index 0000000000..e53c6b8743 --- /dev/null +++ b/src/Microsoft.AspNetCore.CertificateGeneration.Task/CertificateManager.cs @@ -0,0 +1,96 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.CertificateGeneration.Task +{ + internal static class CertificateManager + { + public static X509Certificate2 GenerateSSLCertificate( + string subjectName, + IEnumerable subjectAlternativeName, + string friendlyName, + DateTimeOffset notBefore, + DateTimeOffset expires, + StoreName storeName, + StoreLocation storeLocation) + { + using (var rsa = RSA.Create(2048)) + { + var signingRequest = new CertificateRequest( + new X500DistinguishedName(subjectName), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var enhancedKeyUsage = new OidCollection(); + enhancedKeyUsage.Add(new Oid("1.3.6.1.5.5.7.3.1", "Server Authentication")); + signingRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsage, critical: true)); + signingRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment, critical: true)); + signingRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: true)); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var alternativeName in subjectAlternativeName) + { + sanBuilder.AddDnsName(alternativeName); + } + signingRequest.CertificateExtensions.Add(sanBuilder.Build()); + + var certificate = signingRequest.CreateSelfSigned(notBefore, expires); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + certificate.FriendlyName = friendlyName; + } + + SaveCertificate(storeName, storeLocation, certificate); + + return certificate; + } + } + + private static void SaveCertificate(StoreName storeName, StoreLocation storeLocation, X509Certificate2 certificate) + { + // We need to take this step so that the key gets persisted. + var imported = certificate; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var export = certificate.Export(X509ContentType.Pkcs12, ""); + imported = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet); + Array.Clear(export, 0, export.Length); + } + + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(imported); + store.Close(); + }; + } + + public static X509Certificate2 FindCertificate(string subjectValue, StoreName storeName, StoreLocation storeLocation) + { + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadOnly); + var certificates = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName, subjectValue, validOnly: false); + var current = DateTimeOffset.UtcNow; + + var found = certificates.OfType() + .Where(c => c.NotBefore <= current && current <= c.NotAfter && c.HasPrivateKey) + .FirstOrDefault(); + store.Close(); + + return found; + }; + } + } +} diff --git a/src/Microsoft.AspNetCore.CertificateGeneration.Task/GenerateSSLCertificateTask.cs b/src/Microsoft.AspNetCore.CertificateGeneration.Task/GenerateSSLCertificateTask.cs new file mode 100644 index 0000000000..30ccf8bacb --- /dev/null +++ b/src/Microsoft.AspNetCore.CertificateGeneration.Task/GenerateSSLCertificateTask.cs @@ -0,0 +1,43 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Build.Framework; + +namespace Microsoft.AspNetCore.CertificateGeneration.Task +{ + public class GenerateSSLCertificateTask : Build.Utilities.Task + { + public bool Force { get; set; } + + protected string Subject { get; set; } = "CN=localhost"; + + public override bool Execute() + { + var subjectValue = Subject; + var sansValue = new List { "localhost" }; + var friendlyNameValue = "ASP.NET Core HTTPS development certificate"; + var notBeforeValue = DateTime.UtcNow; + var expiresValue = DateTime.UtcNow.AddYears(1); + var storeNameValue = StoreName.My; + var storeLocationValue = StoreLocation.CurrentUser; + + var cert = CertificateManager.FindCertificate(subjectValue, storeNameValue, storeLocationValue); + + if (cert != null && !Force) + { + LogMessage($"A certificate with subject name '{Subject}' already exists. Skipping certificate generation."); + return true; + } + + var generated = CertificateManager.GenerateSSLCertificate(subjectValue, sansValue, friendlyNameValue, notBeforeValue, expiresValue, storeNameValue, storeLocationValue); + LogMessage($"Generated certificate {generated.SubjectName.Name} - {generated.Thumbprint} - {generated.FriendlyName}"); + + return true; + } + + protected virtual void LogMessage(string message) => Log.LogMessage(MessageImportance.High, message); + } +} diff --git a/src/Microsoft.AspNetCore.CertificateGeneration.Task/Microsoft.AspNetCore.CertificateGeneration.Task.csproj b/src/Microsoft.AspNetCore.CertificateGeneration.Task/Microsoft.AspNetCore.CertificateGeneration.Task.csproj new file mode 100644 index 0000000000..59e932ab97 --- /dev/null +++ b/src/Microsoft.AspNetCore.CertificateGeneration.Task/Microsoft.AspNetCore.CertificateGeneration.Task.csproj @@ -0,0 +1,20 @@ + + + + + + netcoreapp2.0 + MSBuild target for generating HTTPS certificates for development cross-platform. + tools + asp.net;ssl;certificates + + + + + + + + + + + diff --git a/src/Microsoft.AspNetCore.CertificateGeneration.Task/README.md b/src/Microsoft.AspNetCore.CertificateGeneration.Task/README.md new file mode 100644 index 0000000000..4dfea325ab --- /dev/null +++ b/src/Microsoft.AspNetCore.CertificateGeneration.Task/README.md @@ -0,0 +1,48 @@ +Microsoft.AspNetCore.CertificateGeneration.Task +=============================================== +Microsoft.AspNetCore.CertificateGeneration.Task is an MSBuild task to generate SSL certificates +for use in ASP.NET Core for development purposes. + +### How To Install + +Install `Microsoft.AspNetCore.CertificateGeneration.Task` as a `PackageReference` to your project. + +```xml + + + +``` + +### How To Use + +The command must be executed in the directory that contains the project with the reference to the package. + + Usage: dotnet msbuild /t:GenerateSSLCertificate [/p:ForceGenerateSSLCertificate=true] + +### Testing scenarios + +On a machine without an SSL certificate generated by this task. Create a netcoreapp2.0 mvc application using + +``` +dotnet new mvc +``` + +Then try to run the app using: + +``` +dotnet run +``` + +When the application fails to run due to a missing SSL certificate. Run: + +``` +dotnet msbuild /t:GenerateSSLCertificate +``` + +Run the application again using: + +``` +dotnet run +``` + +The application should run successfully. You will still have to trust the certificate as a separate step. diff --git a/src/Microsoft.AspNetCore.CertificateGeneration.Task/build/Microsoft.AspNetCore.CertificateGeneration.Task.targets b/src/Microsoft.AspNetCore.CertificateGeneration.Task/build/Microsoft.AspNetCore.CertificateGeneration.Task.targets new file mode 100644 index 0000000000..1deeb55dbb --- /dev/null +++ b/src/Microsoft.AspNetCore.CertificateGeneration.Task/build/Microsoft.AspNetCore.CertificateGeneration.Task.targets @@ -0,0 +1,20 @@ + + + + + <_SSLCertificateGenerationTaskAssembly>$(MSBuildThisFileDirectory)..\tools\netcoreapp2.0\Microsoft.AspNetCore.CertificateGeneration.Task.dll + + + + + + + + diff --git a/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/GenerateSSLCertificateTaskTest.cs b/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/GenerateSSLCertificateTaskTest.cs new file mode 100644 index 0000000000..245333a84e --- /dev/null +++ b/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/GenerateSSLCertificateTaskTest.cs @@ -0,0 +1,227 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace Microsoft.AspNetCore.CertificateGeneration.Task +{ + public class GenerateSSLCertificateTaskTest : IDisposable + { + private const string TestSubject = "CN=test.ssl.localhost"; + + [Fact] + public void GenerateSSLCertificateTaskTest_CreatesCertificate_IfNoCertificateIsFound() + { + // Arrange + EnsureCleanUp(); + var task = new TestGenerateSSLCertificateTask(); + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(1, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.StartsWith($"Generated certificate {TestSubject}", task.Messages[0]); + } + + [Fact] + public void GenerateSSLCertificateTaskTest_CreatesCertificate_IfFoundCertificateHasExpired() + { + // Arrange + EnsureCleanUp(); + CreateCertificate(notBefore: DateTimeOffset.UtcNow.AddYears(-2), expires: DateTimeOffset.UtcNow.AddYears(-1)); + + var task = new TestGenerateSSLCertificateTask(); + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(2, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.StartsWith($"Generated certificate {TestSubject}", task.Messages[0]); + } + + [Fact] + public void GenerateSSLCertificateTaskTest_CreatesCertificate_IfFoundCertificateIsNotYetValid() + { + // Arrange + EnsureCleanUp(); + CreateCertificate(notBefore: DateTimeOffset.UtcNow.AddYears(1), expires: DateTimeOffset.UtcNow.AddYears(2)); + + var task = new TestGenerateSSLCertificateTask(); + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(2, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.StartsWith($"Generated certificate {TestSubject}", task.Messages[0]); + } + + [Fact] + public void GenerateSSLCertificateTaskTest_CreatesCertificate_IfFoundCertificateDoesNotHavePrivateKeys() + { + // Arrange + EnsureCleanUp(); + CreateCertificate(savePrivateKey: false); + var task = new TestGenerateSSLCertificateTask(); + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(2, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.StartsWith($"Generated certificate {TestSubject}", task.Messages[0]); + } + + [Fact] + public void GenerateSSLCertificateTaskTest_DoesNothing_IfValidCertificateIsFound() + { + // Arrange + EnsureCleanUp(); + CreateCertificate(); + var task = new TestGenerateSSLCertificateTask(); + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(1, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.Equal($"A certificate with subject name '{TestSubject}' already exists. Skipping certificate generation.", task.Messages[0]); + } + + [Fact] + public void GenerateSSLCertificateTaskTest_CreatesACertificateWhenThereIsAlreadyAValidCertificate_IfForceIsSpecified() + { + // Arrange + EnsureCleanUp(); + CreateCertificate(); + var task = new TestGenerateSSLCertificateTask() { Force = true }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var certificates = GetTestCertificates(); + Assert.Equal(2, certificates.Count); + Assert.Equal(1, task.Messages.Count); + Assert.StartsWith($"Generated certificate {TestSubject}", task.Messages[0]); + } + + public X509CertificateCollection GetTestCertificates() + { + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + var certificates = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName, TestSubject, validOnly: false); + store.Close(); + + return certificates; + } + } + + private void EnsureCleanUp() + { + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + var certificates = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName, TestSubject, validOnly: false); + store.RemoveRange(certificates); + store.Close(); + } + } + + public void Dispose() + { + EnsureCleanUp(); + } + + private void CreateCertificate( + DateTimeOffset notBefore = default(DateTimeOffset), + DateTimeOffset expires = default(DateTimeOffset), + bool savePrivateKey = true) + { + using (var rsa = RSA.Create(2048)) + { + var signingRequest = new CertificateRequest( + new X500DistinguishedName(TestSubject), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var enhancedKeyUsage = new OidCollection(); + enhancedKeyUsage.Add(new Oid("1.3.6.1.5.5.7.3.1", "Server Authentication")); + signingRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsage, critical: true)); + signingRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment, critical: true)); + signingRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: true)); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName(TestSubject.Replace("CN=", "")); + signingRequest.CertificateExtensions.Add(sanBuilder.Build()); + + var certificate = signingRequest.CreateSelfSigned( + notBefore == default(DateTimeOffset) ? DateTimeOffset.Now : notBefore, + expires == default(DateTimeOffset) ? DateTimeOffset.Now.AddYears(1) : expires); + + + var imported = certificate; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && savePrivateKey) + { + var export = certificate.Export(X509ContentType.Pkcs12, ""); + + imported = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet); + Array.Clear(export, 0, export.Length); + } + else if (!savePrivateKey) + { + var export = certificate.Export(X509ContentType.Cert, ""); + + imported = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet); + Array.Clear(export, 0, export.Length); + } + + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(imported); + store.Close(); + }; + } + } + + private class TestGenerateSSLCertificateTask : GenerateSSLCertificateTask + { + public TestGenerateSSLCertificateTask() + { + Subject = TestSubject; + } + + public IList Messages { get; set; } = new List(); + + protected override void LogMessage(string message) => Messages.Add(message); + } + } +} diff --git a/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/Microsoft.AspNetCore.CertificateGeneration.Task.Tests.csproj b/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/Microsoft.AspNetCore.CertificateGeneration.Task.Tests.csproj new file mode 100644 index 0000000000..6e956b40f5 --- /dev/null +++ b/test/Microsoft.AspNetcore.CertificateGeneration.Task.Tests/Microsoft.AspNetCore.CertificateGeneration.Task.Tests.csproj @@ -0,0 +1,21 @@ + + + + + + netcoreapp2.0 + + + + + + + + + + + + + + +