diff --git a/appveyor.yml b/appveyor.yml index f45e96fd..9e6d8e8a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -86,8 +86,8 @@ artifacts: deploy: - provider: GitHub - release: v$(CERTES_PACKAGE_VER) - description: 'Certes v$(CERTES_PACKAGE_VER)' + release: v$(CERTES_PACKAGE_VERSION) + description: 'Certes v$(CERTES_PACKAGE_VERSION)' auth_token: secure: B+lTI7i/tnZeg1ZSmho3HvOWjs0C4hptNy5cvWgF0Nn7b6v8nwT/mxEWVCfIJ7Fy artifact: nupkg,cli diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 40e1d1fd..8ce31699 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,11 +2,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + +### Added +- Add `Processing` status for challenges. + +### Changed +- Fix `Content-Type` header for POST requests ([#76][i76]) + +## [2.0.0] - 2018-03-13 ### Added - [ACME v2](APIv2.md) support - Add support for JSON web signature using ECDSA key -- + +## [1.1.4] - 2018-03-04 ### Changed +- Fix error when processing server response without `Content-Type` header - Fix `full-chain-off` option for CLI ## [1.1.3] - 2017-11-23 @@ -34,11 +44,14 @@ All notable changes to this project will be documented in this file. ### Changed - Fix error when parsing directory resource with *meta* property. ([#5][i5]) -[Unreleased]: https://github.com/fszlin/certes/compare/v1.1.3...HEAD +[Unreleased]: https://github.com/fszlin/certes/compare/v2.0.0...HEAD [1.1.0]: https://github.com/fszlin/certes/compare/v1.0.7...v1.1.0 [1.1.1]: https://github.com/fszlin/certes/compare/v1.1.0...v1.1.1 [1.1.2]: https://github.com/fszlin/certes/compare/v1.1.1...v1.1.2 [1.1.3]: https://github.com/fszlin/certes/compare/v1.1.2...v1.1.3 +[1.1.4]: https://github.com/fszlin/certes/compare/v1.1.3...v1.1.4 +[2.0.0]: https://github.com/fszlin/certes/compare/v1.1.4...v2.0.0 [i5]: https://github.com/fszlin/certes/issues/5 [i22]: https://github.com/fszlin/certes/issues/22 +[i76]: https://github.com/fszlin/certes/issues/76 diff --git a/docs/README.md b/docs/README.md index 3c6ad6f8..45c82c55 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,17 +4,14 @@ Certes is an [ACME](https://en.wikipedia.org/wiki/Automated_Certificate_Manageme client runs on .NET 4.5+ and .NET Standard 1.3+, supports ACME v2 and wildcard certificates. It is aimed to provide an easy to use API for managing certificates during deployment processes. -**Util Let's Encrypt releases [v2 endpoint](https://community.letsencrypt.org/t/acmev2-and-wildcard-launch-delay/53654), -please continue to use [v1 API](https://github.com/fszlin/certes/blob/master/docs/README.v1.md) for production.** - ## Usage Install [Certes](https://www.nuget.org/packages/Certes/) nuget package into your project: -``` +```PowerShell Install-Package Certes ``` or using .NET CLI: -``` +```Batchfile dotnet add package Certes ``` @@ -24,7 +21,22 @@ var acme = new AcmeContext(WellKnownServers.LetsEncryptStagingV2); var account = acme.NewAccount("admin@example.com", true); ``` -Place an order for certificate +Place a wildcard certificate order +*(DNS validation is required for wildcard certificates)* +```C# +var order = await acme.NewOrder(new[] { "*.your.domain.name" }); +``` + +Generate the value for DNS TXT record +```C# +var authz = (await order.Authorizations()).First(); +var dnsChallenge = await authz.Dns(); +var dnsTxt = acme.AccountKey.DnsTxt(dnsChallenge.Token); +``` +Add a DNS TXT record to `_acme-challenge.your.domain.name` +with `dnsTxt` value. + +For non-wildcard certificate, HTTP challenge is also available ```C# var order = await acme.NewOrder(new[] { "your.domain.name" }); ``` @@ -36,12 +48,12 @@ var httpChallenge = await authz.Http(); var keyAuthz = httpChallenge.KeyAuthz; ``` -Prepare for http challenge by saving the **key authorization string** -in a text file, and upload it to `http://your.domain.name/.well-known/acme-challenge/` +Save the **key authorization string** in a text file, +and upload it to `http://your.domain.name/.well-known/acme-challenge/` Ask the ACME server to validate our domain ownership ```C# -await httpChallenge.Validate(); +await challenge.Validate(); ``` Download the certificate once validation is done @@ -68,18 +80,41 @@ Check the [APIs](APIv2.md) for more details. *For ACME v1, please see [the doc here](README.v1.md).* +## CLI + +The CLI is available as a dotnet global tool. +.NET Core Runtime 2.1+ *(currently in [preview](https://www.microsoft.com/net/download/dotnet-core/runtime-2.1.0-preview1))* + is required to use dotnet tools. + +To install Certes CLI *(you may need to restart the console session if this is the first dotnet tool installed)* +```Batchfile +dotnet install tool --global dotnet-certes --version 1.0.1-master-812 +``` + +Use the `--help` option to get started +```Batchfile +certes --help +``` + +or check this [AppVeyor script][AppVeyorCliSample] for renewing certificate on Azure webapps. + ## Versioning -We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/fszlin/certes/tags). +We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags](https://github.com/fszlin/certes/tags) on this repository. Also check the [changelog](CHANGELOG.md) to see what's we are working on. ## CI Status -[![NuGet](https://img.shields.io/nuget/vpre/certes.svg)](https://www.nuget.org/packages/certes/absoluteLatest/) +[![NuGet](https://img.shields.io/nuget/vpre/certes.svg?label=Certes)](https://www.nuget.org/packages/certes/absoluteLatest/) [![NuGet](https://img.shields.io/nuget/dt/certes.svg)](https://www.nuget.org/packages/certes/) +[![NuGet](https://img.shields.io/nuget/vpre/dotnet-certes.svg?label=CLI)](https://www.nuget.org/packages/dotnet-certes/absoluteLatest/) +[![NuGet](https://img.shields.io/nuget/dt/dotnet-certes.svg)](https://www.nuget.org/packages/dotnet-certes/) + + [![AppVeyor](https://img.shields.io/appveyor/ci/fszlin/certes/master.svg)](https://ci.appveyor.com/project/fszlin/certes) [![AppVeyor](https://img.shields.io/appveyor/tests/fszlin/certes/master.svg)](https://ci.appveyor.com/project/fszlin/certes/build/tests) [![codecov](https://codecov.io/gh/fszlin/certes/branch/master/graph/badge.svg)](https://codecov.io/gh/fszlin/certes) [![BCH compliance](https://bettercodehub.com/edge/badge/fszlin/certes?branch=master)](https://bettercodehub.com/results/fszlin/certes) [tw]: https://twitter.com/share?url=https%3A%2F%2Fgithub.com%2Ffszlin%2Fcertes&via=certes_acme&related=fszlin&hashtags=certes%2Cssl%2Clets-encrypt%2Cacme%2Chttps&text=get%20free%20SSL%20via%20certes +[AppVeyorCliSample]: https://github.com/fszlin/lo0.in/blob/79fc1561ca4aa29de7741ad5590e53be8db34690/.appveyor.yml#L43-L56 diff --git a/src/Certes.Cli/Certes.Cli.csproj b/src/Certes.Cli/Certes.Cli.csproj index 0fc9cb2c..a88a64eb 100644 --- a/src/Certes.Cli/Certes.Cli.csproj +++ b/src/Certes.Cli/Certes.Cli.csproj @@ -5,7 +5,7 @@ Exe netcoreapp1.0;netcoreapp2.0 netcoreapp2.0 - 1.0.0 + 1.0.1 $(AssemblyVersion)$(CertesPackageVersionSuffix) $(AssemblyVersion)$(CertesFileVersionSuffix) $(AssemblyVersion)$(CertesInformationalVersionSuffix) diff --git a/src/Certes.Cli/Commands/AzureAppCommand.cs b/src/Certes.Cli/Commands/AzureAppCommand.cs index 5fa90598..4b07c8b2 100644 --- a/src/Certes.Cli/Commands/AzureAppCommand.cs +++ b/src/Certes.Cli/Commands/AzureAppCommand.cs @@ -1,7 +1,10 @@ using System; using System.CommandLine; +using System.Globalization; +using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Certes.Cli.Settings; +using Certes.Pkcs; using Microsoft.Azure.Management.AppService.Fluent; using Microsoft.Azure.Management.AppService.Fluent.Models; @@ -70,29 +73,24 @@ public async Task Execute(ArgumentSyntax syntax) } var cert = await orderCtx.Download(); - - var pfxName = $"{order.Certificate} by certes"; - var pfxPassword = Guid.NewGuid().ToString("N"); - var pfx = cert.ToPfx(privKey); - var pfxBytes = pfx.Build(pfxName, pfxPassword); - - var certData = new CertificateInner - { - PfxBlob = pfxBytes, - Password = pfxPassword, - }; + var x509Cert = new X509Certificate2(cert.Certificate.ToDer()); + var thumbprint = x509Cert.Thumbprint; using (var client = clientFactory.Invoke(azureCredentials)) { client.SubscriptionId = azureCredentials.DefaultSubscriptionId; - var certUpdated = await client.Certificates.CreateOrUpdateAsync( - resourceGroup, pfxName, certData); + var certUploaded = await FindCertificate(client, resourceGroup, thumbprint); + if (certUploaded == null) + { + certUploaded = await UploadCertificate( + client, resourceGroup, appName, appSlot, cert.ToPfx(privKey), thumbprint); + } var hostNameBinding = new HostNameBindingInner { SslState = SslState.SniEnabled, - Thumbprint = certUpdated.Thumbprint, + Thumbprint = certUploaded.Thumbprint, }; var hostName = string.IsNullOrWhiteSpace(appSlot) ? @@ -107,5 +105,48 @@ await client.WebApps.CreateOrUpdateHostNameBindingSlotAsync( }; } } + + private static async Task UploadCertificate( + IWebSiteManagementClient client, string resourceGroup, string appName, string appSlot, PfxBuilder pfx, string thumbprint) + { + var pfxName = string.Format(CultureInfo.InvariantCulture, "[certes] {0:yyyyMMddhhmmss}", DateTime.UtcNow); + var pfxPassword = Guid.NewGuid().ToString("N"); + var pfxBytes = pfx.Build(pfxName, pfxPassword); + + var webApp = string.IsNullOrWhiteSpace(appSlot) ? + await client.WebApps.GetAsync(resourceGroup, appName) : + await client.WebApps.GetSlotAsync(resourceGroup, appName, appSlot); + + var certData = new CertificateInner + { + PfxBlob = pfxBytes, + Password = pfxPassword, + Location = webApp.Location, + }; + + return await client.Certificates.CreateOrUpdateAsync( + resourceGroup, thumbprint, certData); + } + + private static async Task FindCertificate( + IWebSiteManagementClient client, string resourceGroup, string thumbprint) + { + var certificates = await client.Certificates.ListByResourceGroupAsync(resourceGroup); + while (certificates != null) + { + foreach (var azCert in certificates) + { + if (string.Equals(azCert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) + { + return azCert; + } + } + + certificates = certificates.NextPageLink == null ? null : + await client.Certificates.ListByResourceGroupNextAsync(certificates.NextPageLink); + } + + return null; + } } } diff --git a/src/Certes.Cli/Commands/CertificatePemCommand.cs b/src/Certes.Cli/Commands/CertificatePemCommand.cs index 4deffba8..8d9d526d 100644 --- a/src/Certes.Cli/Commands/CertificatePemCommand.cs +++ b/src/Certes.Cli/Commands/CertificatePemCommand.cs @@ -66,7 +66,7 @@ public async Task Execute(ArgumentSyntax syntax) return new { - location = location, + location, }; } diff --git a/src/Certes.Cli/Commands/CertificatePfxCommand.cs b/src/Certes.Cli/Commands/CertificatePfxCommand.cs index 6acd1c7b..3c20f655 100644 --- a/src/Certes.Cli/Commands/CertificatePfxCommand.cs +++ b/src/Certes.Cli/Commands/CertificatePfxCommand.cs @@ -1,4 +1,6 @@ -using System.CommandLine; +using System; +using System.CommandLine; +using System.Globalization; using System.Threading.Tasks; using Certes.Cli.Settings; using NLog; @@ -46,8 +48,9 @@ public async Task Execute(ArgumentSyntax syntax) var pwd = syntax.GetParameter(PasswordParam, true); var (location, cert) = await DownloadCertificate(syntax); + var pfxName = string.Format(CultureInfo.InvariantCulture, "[certes] {0:yyyyMMddhhmmss}", DateTime.UtcNow); var privKey = await syntax.ReadKey(PrivateKeyOption, "CERTES_CERT_KEY", File, environment, true); - var pfx = cert.ToPfx(privKey).Build($"{location} by certes", pwd); + var pfx = cert.ToPfx(privKey).Build(pfxName, pwd); var outPath = syntax.GetOption(OutOption); if (string.IsNullOrWhiteSpace(outPath)) diff --git a/src/Certes/Acme/AcmeHttpClient.cs b/src/Certes/Acme/AcmeHttpClient.cs index eb3ca0e1..11565cb1 100644 --- a/src/Certes/Acme/AcmeHttpClient.cs +++ b/src/Certes/Acme/AcmeHttpClient.cs @@ -72,6 +72,8 @@ public async Task> Post(Uri uri, object payload) { var payloadJson = JsonConvert.SerializeObject(payload, Formatting.None, jsonSettings); var content = new StringContent(payloadJson, Encoding.UTF8, MimeJoseJson); + // boulder will reject the request if sending charset=utf-8 + content.Headers.ContentType.CharSet = null; using (var response = await Http.PostAsync(uri, content)) { return await ProcessResponse(response); diff --git a/src/Certes/Acme/ChallengeContext.cs b/src/Certes/Acme/ChallengeContext.cs index cd53c734..2165ea0a 100644 --- a/src/Certes/Acme/ChallengeContext.cs +++ b/src/Certes/Acme/ChallengeContext.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Certes.Acme.Resource; namespace Certes.Acme { @@ -7,7 +8,7 @@ namespace Certes.Acme /// Represents the context for ACME challenge operations. /// /// - internal class ChallengeContext : EntityContext, IChallengeContext + internal class ChallengeContext : EntityContext, IChallengeContext { /// /// Initializes a new instance of the class. @@ -57,13 +58,10 @@ public ChallengeContext( /// /// The challenge. /// - public async Task Validate() + public async Task Validate() { - var payload = await Context.Sign( - new Resource.Challenge { - KeyAuthorization = KeyAuthz - }, Location); - var resp = await Context.HttpClient.Post(Location, payload, true); + var payload = await Context.Sign(new { }, Location); + var resp = await Context.HttpClient.Post(Location, payload, true); return resp.Resource; } } diff --git a/src/Certes/Acme/Resource/AuthorizationStatus.cs b/src/Certes/Acme/Resource/AuthorizationStatus.cs index 5b623597..1428e93e 100644 --- a/src/Certes/Acme/Resource/AuthorizationStatus.cs +++ b/src/Certes/Acme/Resource/AuthorizationStatus.cs @@ -1,6 +1,7 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System; using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace Certes.Acme.Resource { @@ -19,6 +20,7 @@ public enum AuthorizationStatus /// /// The processing status. /// + [Obsolete("Use ChallengeStatus.Processing instead.")] [EnumMember(Value = "processing")] Processing, diff --git a/src/Certes/Acme/Resource/ChallengeStatus.cs b/src/Certes/Acme/Resource/ChallengeStatus.cs index cb29ad41..d7b043d3 100644 --- a/src/Certes/Acme/Resource/ChallengeStatus.cs +++ b/src/Certes/Acme/Resource/ChallengeStatus.cs @@ -15,6 +15,12 @@ public enum ChallengeStatus [JsonProperty("pending")] Pending, + /// + /// The processing status. + /// + [JsonProperty("processing")] + Processing, + /// /// The valid status. /// diff --git a/src/Certes/Acme/Resource/OrderStatus.cs b/src/Certes/Acme/Resource/OrderStatus.cs index c3723ee9..36d852ae 100644 --- a/src/Certes/Acme/Resource/OrderStatus.cs +++ b/src/Certes/Acme/Resource/OrderStatus.cs @@ -19,6 +19,12 @@ public enum OrderStatus [EnumMember(Value = "pending")] Pending, + /// + /// The ready status. + /// + [EnumMember(Value = "ready")] + Ready, + /// /// The processing status. /// diff --git a/src/Certes/Certes.csproj b/src/Certes/Certes.csproj index 97c463f5..2c19a457 100644 --- a/src/Certes/Certes.csproj +++ b/src/Certes/Certes.csproj @@ -3,7 +3,7 @@ netstandard2.0;netstandard1.3;net45;net47 - 2.0.0 + 2.0.1 $(AssemblyVersion)$(CertesPackageVersionSuffix) $(AssemblyVersion)$(CertesFileVersionSuffix) $(AssemblyVersion)$(CertesInformationalVersionSuffix) diff --git a/test/Certes.Tests.Web/Certes.Tests.Web.csproj b/test/Certes.Tests.Web/Certes.Tests.Web.csproj index b3be4b48..dc743d2b 100644 --- a/test/Certes.Tests.Web/Certes.Tests.Web.csproj +++ b/test/Certes.Tests.Web/Certes.Tests.Web.csproj @@ -21,7 +21,7 @@ - + diff --git a/test/Certes.Tests/Acme/Resource/AuthorizationTests.cs b/test/Certes.Tests/Acme/Resource/AuthorizationTests.cs index eaee48bf..498e6332 100644 --- a/test/Certes.Tests/Acme/Resource/AuthorizationTests.cs +++ b/test/Certes.Tests/Acme/Resource/AuthorizationTests.cs @@ -10,7 +10,7 @@ public class AuthorizationTests public void CanGetSetProperties() { var authz = new Authorization(); - authz.VerifyGetterSetter(a => a.Status, AuthorizationStatus.Processing); + authz.VerifyGetterSetter(a => a.Status, AuthorizationStatus.Deactivated); authz.VerifyGetterSetter(a => a.Challenges, new[] { new Challenge() }); authz.VerifyGetterSetter(a => a.Expires, DateTimeOffset.Now); authz.VerifyGetterSetter(a => a.Identifier, new Identifier()); diff --git a/test/Certes.Tests/Cli/Commands/AzureAppCommandTests.cs b/test/Certes.Tests/Cli/Commands/AzureAppCommandTests.cs index e040519c..6eb4cdcf 100644 --- a/test/Certes.Tests/Cli/Commands/AzureAppCommandTests.cs +++ b/test/Certes.Tests/Cli/Commands/AzureAppCommandTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using Certes.Acme; @@ -11,6 +12,7 @@ using Microsoft.Azure.Management.AppService.Fluent.Models; using Microsoft.Rest.Azure; using Moq; +using Newtonsoft.Json; using Xunit; using static Certes.Acme.WellKnownServers; using static Certes.Cli.CliTestHelper; @@ -27,6 +29,7 @@ public async Task CanProcessCommand() var orderLoc = new Uri("http://acme.com/o/1"); var resourceGroup = "resGroup"; var appName = "my-app"; + var appSlot = "staging"; var keyPath = "./cert-key.pem"; var certChainContent = string.Join( @@ -75,6 +78,16 @@ public async Task CanProcessCommand() appSvcMock.SetupGet(m => m.Certificates).Returns(certOpMock.Object); appSvcMock.Setup(m => m.Dispose()); + certOpMock.Setup(m => m.ListByResourceGroupWithHttpMessagesAsync(resourceGroup, default, default)) + .ReturnsAsync(new AzureOperationResponse> + { + Body = JsonConvert.DeserializeObject>( + JsonConvert.SerializeObject(new + { + value = new CertificateInner[0] + }) + ) + }); certOpMock.Setup(m => m.CreateOrUpdateWithHttpMessagesAsync(resourceGroup, It.IsAny(), It.IsAny(), default, default)) .ReturnsAsync((string r, string n, CertificateInner c, Dictionary> h, CancellationToken t) => new AzureOperationResponse { Body = c }); @@ -83,6 +96,22 @@ public async Task CanProcessCommand() resourceGroup, appName, domain, It.IsAny(), default, default)) .ReturnsAsync((string r, string a, string n, HostNameBindingInner d, Dictionary> h, CancellationToken t) => new AzureOperationResponse { Body = d }); + webAppOpMock.Setup(m => m.GetWithHttpMessagesAsync(resourceGroup, appName, default, default)) + .ReturnsAsync(new AzureOperationResponse + { + Body = new SiteInner + { + Location = "Canada" + } + }); + webAppOpMock.Setup(m => m.GetSlotWithHttpMessagesAsync(resourceGroup, appName, appSlot, default, default)) + .ReturnsAsync(new AzureOperationResponse + { + Body = new SiteInner + { + Location = "Canada" + } + }); var envMock = new Mock(MockBehavior.Strict); @@ -100,7 +129,6 @@ public async Task CanProcessCommand() resourceGroup, appName, domain, It.IsAny(), default, default), Times.Once); // with deployment slot - var appSlot = "staging"; webAppOpMock.Setup(m => m.CreateOrUpdateHostNameBindingSlotWithHttpMessagesAsync( resourceGroup, appName, domain, It.IsAny(), appSlot, default, default)) .ReturnsAsync((string r, string a, string n, HostNameBindingInner d, string s, Dictionary> h, CancellationToken t) @@ -116,6 +144,30 @@ public async Task CanProcessCommand() webAppOpMock.Verify(m => m.CreateOrUpdateHostNameBindingSlotWithHttpMessagesAsync( resourceGroup, appName, domain, It.IsAny(), appSlot, default, default), Times.Once); + var cert = new X509Certificate2(certChain.Certificate.ToDer()); + certOpMock.Setup(m => m.ListByResourceGroupWithHttpMessagesAsync(resourceGroup, default, default)) + .ReturnsAsync(new AzureOperationResponse> + { + Body = JsonConvert.DeserializeObject>( + JsonConvert.SerializeObject(new + { + value = new CertificateInner[] + { + new CertificateInner("certes", thumbprint: "another-cert"), + new CertificateInner("certes", thumbprint: cert.Thumbprint) + } + }) + ) + }); + + args = $"app {orderLoc} {domain} {appName} --private-key {keyPath}" + + $" --talent-id talentId --client-id clientId --client-secret abcd1234" + + $" --subscription-id {Guid.NewGuid()} --resource-group {resourceGroup}"; + syntax = DefineCommand(args); + ret = await cmd.Execute(syntax); + Assert.NotNull(ret.data); + Assert.Equal(cert.Thumbprint, ret.data.Thumbprint); + // order incompleted orderMock.Setup(m => m.Resource()).ReturnsAsync(new Order()); args = $"app {orderLoc} {domain} {appName} --private-key {keyPath}"