From 5c370d7432e288ce64e1142590b7a2c675866430 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Tue, 21 Jan 2025 06:57:02 -0600 Subject: [PATCH 1/3] Adds support for ARI renewal info: https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ Signed-off-by: Whit Waldo --- src/Certes/Acme/Resource/Directory.cs | 11 +++- src/Certes/Acme/Resource/Order.cs | 9 ++- src/Certes/Acme/Resource/RenewalInfo.cs | 21 ++++++ src/Certes/Acme/Resource/SuggestedWindow.cs | 22 +++++++ src/Certes/AcmeContext.cs | 66 ++++++++++++++++++- .../Acme/Resource/DirectoryTests.cs | 5 +- test/Certes.Tests/Acme/Resource/OrderTests.cs | 1 + test/Certes.Tests/Helper.v2.cs | 3 +- .../IAcmeContextExtensionsTests.cs | 6 +- 9 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 src/Certes/Acme/Resource/RenewalInfo.cs create mode 100644 src/Certes/Acme/Resource/SuggestedWindow.cs diff --git a/src/Certes/Acme/Resource/Directory.cs b/src/Certes/Acme/Resource/Directory.cs index 8090739ff..098ab96f8 100644 --- a/src/Certes/Acme/Resource/Directory.cs +++ b/src/Certes/Acme/Resource/Directory.cs @@ -61,6 +61,12 @@ public class Directory /// [JsonProperty("meta")] public DirectoryMeta Meta { get; } + + /// + /// Gets or sets the renewal info. + /// + [JsonProperty("renewalInfo")] + public Uri RenewalInfo { get; } /// /// Initializes a new instance of the class. @@ -71,13 +77,15 @@ public class Directory /// The revoke cert. /// The key change. /// The meta. + /// The renewal info. public Directory( Uri newNonce, Uri newAccount, Uri newOrder, Uri revokeCert, Uri keyChange, - DirectoryMeta meta) + DirectoryMeta meta, + Uri renewalInfo) { NewNonce = newNonce; NewAccount = newAccount; @@ -85,6 +93,7 @@ public Directory( RevokeCert = revokeCert; KeyChange = keyChange; Meta = meta; + RenewalInfo = renewalInfo; } } } diff --git a/src/Certes/Acme/Resource/Order.cs b/src/Certes/Acme/Resource/Order.cs index 956a6607f..e81de86e2 100644 --- a/src/Certes/Acme/Resource/Order.cs +++ b/src/Certes/Acme/Resource/Order.cs @@ -94,7 +94,14 @@ public class Order /// [JsonProperty("certificate")] public Uri Certificate { get; set; } - + + /// + /// An optional string uniquely identifying a previously-issued + /// certificate which this order is intended to replace. + /// + [JsonProperty("replaces")] + public string Replaces { get; set; } + /// /// Represents the payload to finalize an order. /// diff --git a/src/Certes/Acme/Resource/RenewalInfo.cs b/src/Certes/Acme/Resource/RenewalInfo.cs new file mode 100644 index 000000000..af09f26cf --- /dev/null +++ b/src/Certes/Acme/Resource/RenewalInfo.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Certes.Acme.Resource; + +/// +/// Represents the renewal info for an ACME directory. +/// +public class RenewalInfo +{ + /// + /// The recommended renewal period. + /// + [JsonProperty("suggestedWindow")] + public SuggestedWindow SuggestedWindow { get; set; } + + /// + /// Provides additional context about the renewal suggestion. + /// + [JsonProperty("explanationURL")] + public string ExplanationUrl { get; set; } +} diff --git a/src/Certes/Acme/Resource/SuggestedWindow.cs b/src/Certes/Acme/Resource/SuggestedWindow.cs new file mode 100644 index 000000000..8b845e5fa --- /dev/null +++ b/src/Certes/Acme/Resource/SuggestedWindow.cs @@ -0,0 +1,22 @@ +using System; +using Newtonsoft.Json; + +namespace Certes.Acme.Resource; + +/// +/// Reflects the recommended renewal window for a certificate. +/// +public sealed class SuggestedWindow +{ + /// + /// The start of the recommended renewal period. + /// + [JsonProperty("start")] + public DateTimeOffset Start { get; set; } + + /// + /// The end of the recommended renewal period. + /// + [JsonProperty("end")] + public DateTimeOffset End { get; set; } +} diff --git a/src/Certes/AcmeContext.cs b/src/Certes/AcmeContext.cs index c7b5fcb89..150060688 100644 --- a/src/Certes/AcmeContext.cs +++ b/src/Certes/AcmeContext.cs @@ -1,10 +1,15 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using Certes.Acme; using Certes.Acme.Resource; using Certes.Jws; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Pkcs; +using Directory = Certes.Acme.Resource.Directory; using Identifier = Certes.Acme.Resource.Identifier; using IdentifierType = Certes.Acme.Resource.IdentifierType; @@ -178,10 +183,10 @@ public async Task RevokeCertificate(byte[] certificate, RevocationReason reason, } /// - /// Creates a new the order. + /// Creates a new order. /// /// The identifiers. - /// Th value of not before field for the certificate. + /// The value of not before field for the certificate. /// The value of not after field for the certificate. /// /// The order context created. @@ -203,6 +208,35 @@ public async Task NewOrder(IList identifiers, DateTimeOff return new OrderContext(this, order.Location); } + /// + /// Creates a new order replacing an existing certificate as part of an ARI renewal in the suggested window. + /// + /// The identifiers. + /// The identifier of the certificate being renewed as part + /// of the ARI renewal suggestion window. + /// The value of not before field for the certificate. + /// The value of not after field for the certificate. + /// + /// The order context created. + /// + public async Task NewOrder(IList identifiers, string ariCertificateId, DateTimeOffset? notBefore = null, DateTimeOffset? notAfter = null) + { + var endpoint = await this.GetResourceUri(d => d.NewOrder); + + var body = new Order + { + Identifiers = identifiers + .Select(id => new Identifier { Type = IdentifierType.Dns, Value = id }) + .ToArray(), + Replaces = ariCertificateId, + NotBefore = notBefore, + NotAfter = notAfter, + }; + + var order = await HttpClient.Post(this, endpoint, body, true); + return new OrderContext(this, order.Location); + } + /// /// Signs the data with account key. /// @@ -236,5 +270,33 @@ public IOrderContext Order(Uri location) /// public IAuthorizationContext Authorization(Uri location) => new AuthorizationContext(this, location); + + /// + /// Gets the certificate ID for an ACME renewal information request. + /// + /// The issued PFX certificate bytes to create the request from. + /// The password for the issued PFX certificate. + /// The ARI certificate ID to use in the renewal info URL and order. + public static string GetAriCertificateId(byte[] certificate, string password) + { + using var memoryStream = new MemoryStream(certificate); + var pkcs12Store = new Pkcs12Store(memoryStream, password.ToCharArray()); + + var alias = pkcs12Store.Aliases.Cast() + .FirstOrDefault(currentAlias => pkcs12Store.IsCertificateEntry(currentAlias)); + + var certEntry = pkcs12Store.GetCertificate(alias); + var cert = certEntry.Certificate; + var akiExtension = cert.GetExtensionValue(X509Extensions.AuthorityKeyIdentifier); + var base64Aki = Convert.ToBase64String(akiExtension.GetOctets()).Replace("=", string.Empty); + + var aki = AuthorityKeyIdentifier.GetInstance(Asn1Object.FromByteArray(akiExtension.GetOctets())); + var serialNumber = aki.AuthorityCertSerialNumber; + var serialNumberBytes = serialNumber.ToByteArray().Skip(2).ToArray(); + var serialNumberDer = BitConverter.ToString(serialNumberBytes).Replace("=", string.Empty); + + var ariCertId = $"{base64Aki}.{serialNumberDer}"; + return ariCertId; + } } } diff --git a/test/Certes.Tests/Acme/Resource/DirectoryTests.cs b/test/Certes.Tests/Acme/Resource/DirectoryTests.cs index da962a9e3..0bbb5a8da 100644 --- a/test/Certes.Tests/Acme/Resource/DirectoryTests.cs +++ b/test/Certes.Tests/Acme/Resource/DirectoryTests.cs @@ -15,6 +15,7 @@ public void CanGetSetProperties() KeyChange = new Uri("http://KeyChange.is.working"), NewAccount = new Uri("http://NewAccount.is.working"), NewOrder = new Uri("http://NewOrder.is.working"), + RenewalInfo = new Uri("http://RenewalInfo.is.working"), Meta = new DirectoryMeta(new Uri("http://certes.is.working"), null, null, null), }; @@ -24,7 +25,8 @@ public void CanGetSetProperties() data.NewOrder, data.RevokeCert, data.KeyChange, - data.Meta); + data.Meta, + data.RenewalInfo); Assert.Equal(data.NewNonce, model.NewNonce); Assert.Equal(data.NewAccount, model.NewAccount); @@ -32,6 +34,7 @@ public void CanGetSetProperties() Assert.Equal(data.RevokeCert, model.RevokeCert); Assert.Equal(data.KeyChange, model.KeyChange); Assert.Equal(data.Meta.Website, model.Meta?.Website); + Assert.Equal(data.RenewalInfo, model.RenewalInfo); } } } diff --git a/test/Certes.Tests/Acme/Resource/OrderTests.cs b/test/Certes.Tests/Acme/Resource/OrderTests.cs index b44346a5b..78ad6ca6a 100644 --- a/test/Certes.Tests/Acme/Resource/OrderTests.cs +++ b/test/Certes.Tests/Acme/Resource/OrderTests.cs @@ -18,6 +18,7 @@ public void CanGetSetProperties() entity.VerifyGetterSetter(a => a.NotBefore, DateTimeOffset.Now.AddDays(-1)); entity.VerifyGetterSetter(a => a.Status, OrderStatus.Processing); entity.VerifyGetterSetter(a => a.Identifiers, new List()); + entity.VerifyGetterSetter(a => a.Replaces, "working fine"); var r = new Order.Payload(); r.VerifyGetterSetter(a => a.Csr, "certes is working"); diff --git a/test/Certes.Tests/Helper.v2.cs b/test/Certes.Tests/Helper.v2.cs index ff3e9c13a..d360d7cfc 100644 --- a/test/Certes.Tests/Helper.v2.cs +++ b/test/Certes.Tests/Helper.v2.cs @@ -14,7 +14,8 @@ public static partial class Helper new Uri("http://acme.d/newOrder"), new Uri("http://acme.d/revokeCert"), new Uri("http://acme.d/keyChange"), - new DirectoryMeta(new Uri("http://acme.d/tos"), null, null, false)); + new DirectoryMeta(new Uri("http://acme.d/tos"), null, null, false), + new Uri("http://acme.d/renewalInfo")); public static IKey GetKeyV2(KeyAlgorithm algo = KeyAlgorithm.ES256) => KeyFactory.FromPem(algo.GetTestKey()); diff --git a/test/Certes.Tests/IAcmeContextExtensionsTests.cs b/test/Certes.Tests/IAcmeContextExtensionsTests.cs index cbd89c017..8a4a7c4f0 100644 --- a/test/Certes.Tests/IAcmeContextExtensionsTests.cs +++ b/test/Certes.Tests/IAcmeContextExtensionsTests.cs @@ -14,15 +14,15 @@ public async Task CanGetTos() var tosUri = new Uri("http://acme.d/tos"); var ctxMock = new Mock(); ctxMock.Setup(m => m.GetDirectory()).ReturnsAsync( - new Directory(null, null, null, null, null, new DirectoryMeta(tosUri, null, null, null))); + new Directory(null, null, null, null, null, new DirectoryMeta(tosUri, null, null, null), null)); Assert.Equal(tosUri, await ctxMock.Object.TermsOfService()); ctxMock.Setup(m => m.GetDirectory()).ReturnsAsync( - new Directory(null, null, null, null, null, new DirectoryMeta(null, null, null, null))); + new Directory(null, null, null, null, null, new DirectoryMeta(null, null, null, null), null)); Assert.Null(await ctxMock.Object.TermsOfService()); ctxMock.Setup(m => m.GetDirectory()).ReturnsAsync( - new Directory(null, null, null, null, null, null)); + new Directory(null, null, null, null, null, null, null)); Assert.Null(await ctxMock.Object.TermsOfService()); } } From 6c7fb78de873a7f409444ba5be24218c3a324032 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Tue, 21 Jan 2025 07:30:35 -0600 Subject: [PATCH 2/3] Updated documentation Signed-off-by: Whit Waldo --- docs/APIv2.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/APIv2.md b/docs/APIv2.md index 237f402b5..4f406b76d 100644 --- a/docs/APIv2.md +++ b/docs/APIv2.md @@ -270,6 +270,37 @@ Revoke certificate with certificate private key. context.RevokeCertificate(cert.ToDer(), RevocationReason.KeyCompromise, certKey); ``` +## Renewals via ACME Renewal Info (ARI) +The ACME Renewal Info allows clients to periodically check with Let's Encrypt servers to determine +if your existing certificate should be renewed. After your certificate is issued, generate an +ARI Certificate ID using `AcmeContext.GetAriCertificateId()` by passing in the bytes of the PFX +certificate and its password. + +```C# +//Starting from the last section when the certificate is issued... +var pfx = cert.ToPfx("cert-name", "password"); +var ariCertificateId = AcmeContext.GetAriCertificateId(pfx, "password"); +``` + +Periodically check the `RenewalInfo` endpoint in the `Directory` by +appending this ARI Certificate ID as a suffix to that URL and when eligible, schedule your certificate's +renewal within the suggested window given. + +```C# +var renewalInfoUrl = AcmeContext.GetDirectory().RenewalInfo; +var combinedUrl = new Uri(renewalInfoUrl, $"/{ariCertificateId}"); +//Query this URL for a suggested renewal interval +``` + +During renewal, proceed as you normally would. When you get to the step where you'd typically have called +`NewOrder`, instead use the new overload to pass the ARI Certificate ID into the second argument so the +renewal request can be correlated. + +```C# +var order = await context.NewOrder(new [] { "*.example.com" }, ariCertificateId); +//Proceed normally +``` +