Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ARI protocol #329

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/APIv2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<!---
## Not Implemented
* Account
Expand Down
11 changes: 10 additions & 1 deletion src/Certes/Acme/Resource/Directory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public class Directory
/// </value>
[JsonProperty("meta")]
public DirectoryMeta Meta { get; }

/// <summary>
/// Gets or sets the renewal info.
/// </summary>
[JsonProperty("renewalInfo")]
public Uri RenewalInfo { get; }

/// <summary>
/// Initializes a new instance of the <see cref="Directory"/> class.
Expand All @@ -71,20 +77,23 @@ public class Directory
/// <param name="revokeCert">The revoke cert.</param>
/// <param name="keyChange">The key change.</param>
/// <param name="meta">The meta.</param>
/// <param name="renewalInfo">The renewal info.</param>
public Directory(
Uri newNonce,
Uri newAccount,
Uri newOrder,
Uri revokeCert,
Uri keyChange,
DirectoryMeta meta)
DirectoryMeta meta,
Uri renewalInfo)
{
NewNonce = newNonce;
NewAccount = newAccount;
NewOrder = newOrder;
RevokeCert = revokeCert;
KeyChange = keyChange;
Meta = meta;
RenewalInfo = renewalInfo;
}
}
}
9 changes: 8 additions & 1 deletion src/Certes/Acme/Resource/Order.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,14 @@ public class Order
/// </value>
[JsonProperty("certificate")]
public Uri Certificate { get; set; }


/// <summary>
/// An optional string uniquely identifying a previously-issued
/// certificate which this order is intended to replace.
/// </summary>
[JsonProperty("replaces")]
public string Replaces { get; set; }

/// <summary>
/// Represents the payload to finalize an order.
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions src/Certes/Acme/Resource/RenewalInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Newtonsoft.Json;

namespace Certes.Acme.Resource;

/// <summary>
/// Represents the renewal info for an ACME directory.
/// </summary>
public class RenewalInfo
{
/// <summary>
/// The recommended renewal period.
/// </summary>
[JsonProperty("suggestedWindow")]
public SuggestedWindow SuggestedWindow { get; set; }

/// <summary>
/// Provides additional context about the renewal suggestion.
/// </summary>
[JsonProperty("explanationURL")]
public string ExplanationUrl { get; set; }
}
22 changes: 22 additions & 0 deletions src/Certes/Acme/Resource/SuggestedWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using Newtonsoft.Json;

namespace Certes.Acme.Resource;

/// <summary>
/// Reflects the recommended renewal window for a certificate.
/// </summary>
public sealed class SuggestedWindow
{
/// <summary>
/// The start of the recommended renewal period.
/// </summary>
[JsonProperty("start")]
public DateTimeOffset Start { get; set; }

/// <summary>
/// The end of the recommended renewal period.
/// </summary>
[JsonProperty("end")]
public DateTimeOffset End { get; set; }
}
71 changes: 69 additions & 2 deletions src/Certes/AcmeContext.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -178,10 +183,10 @@ public async Task RevokeCertificate(byte[] certificate, RevocationReason reason,
}

/// <summary>
/// Creates a new the order.
/// Creates a new order.
/// </summary>
/// <param name="identifiers">The identifiers.</param>
/// <param name="notBefore">Th value of not before field for the certificate.</param>
/// <param name="notBefore">The value of not before field for the certificate.</param>
/// <param name="notAfter">The value of not after field for the certificate.</param>
/// <returns>
/// The order context created.
Expand All @@ -203,6 +208,35 @@ public async Task<IOrderContext> NewOrder(IList<string> identifiers, DateTimeOff
return new OrderContext(this, order.Location);
}

/// <summary>
/// Creates a new order replacing an existing certificate as part of an ARI renewal in the suggested window.
/// </summary>
/// <param name="identifiers">The identifiers.</param>
/// <param name="ariCertificateId">The identifier of the certificate being renewed as part
/// of the ARI renewal suggestion window.</param>
/// <param name="notBefore">The value of not before field for the certificate.</param>
/// <param name="notAfter">The value of not after field for the certificate.</param>
/// <returns>
/// The order context created.
/// </returns>
public async Task<IOrderContext> NewOrder(IList<string> 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<Order>(this, endpoint, body, true);
return new OrderContext(this, order.Location);
}

/// <summary>
/// Signs the data with account key.
/// </summary>
Expand Down Expand Up @@ -236,5 +270,38 @@ public IOrderContext Order(Uri location)
/// </returns>
public IAuthorizationContext Authorization(Uri location)
=> new AuthorizationContext(this, location);

/// <summary>
/// Gets the certificate ID for an ACME renewal information request.
/// </summary>
/// <param name="certificate">The issued PFX certificate bytes to create the request from.</param>
/// <param name="password">The password for the issued PFX certificate.</param>
/// <returns>The ARI certificate ID to use in the renewal info URL and order.</returns>
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<string>()
.FirstOrDefault(currentAlias => pkcs12Store.IsCertificateEntry(currentAlias) || pkcs12Store.IsKeyEntry(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 serialNumber = cert.SerialNumber;
byte[] serialNumberDer;
using (var derStream = new MemoryStream())
{
var derOutputStream = Asn1OutputStream.Create(derStream);
derOutputStream.WriteObject(new DerInteger(serialNumber));
serialNumberDer = derStream.ToArray().Skip(2).ToArray(); //Skip the first two bytes
}
var base64SerialNumber = Convert.ToBase64String(serialNumberDer).Replace("=", string.Empty);

var ariCertId = $"{base64Aki}.{base64SerialNumber}";
return ariCertId;
}
}
}
14 changes: 14 additions & 0 deletions test/Certes.Tests/Acme/AcmeContextTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Certes.Acme.Resource;
using Moq;
using Xunit;

using static Certes.Helper;
using Directory = Certes.Acme.Resource.Directory;

namespace Certes.Acme
{
Expand Down Expand Up @@ -91,5 +93,17 @@ public async Task CanRevokeCertByPrivateKey()
var client = new AcmeContext(directoryUri, http: httpClientMock.Object);
await client.RevokeCertificate(certData, RevocationReason.KeyCompromise, certKey);
}

[Fact]
public async Task CanCreateARICertificateId()
{
const string filePath = "./Sample/example.pfx.base64";
var base64Pfx = await File.ReadAllTextAsync(filePath);
var pfxBytes = Convert.FromBase64String(base64Pfx);

var result = AcmeContext.GetAriCertificateId(pfxBytes, "abc123");

Assert.Equal("MBaAFCc2NSiyPMHMMB9tRrZeS+Y963by.B1vNFQ", result);
}
}
}
5 changes: 4 additions & 1 deletion test/Certes.Tests/Acme/Resource/DirectoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};

Expand All @@ -24,14 +25,16 @@ 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);
Assert.Equal(data.NewOrder, model.NewOrder);
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);
}
}
}
1 change: 1 addition & 0 deletions test/Certes.Tests/Acme/Resource/OrderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Identifier>());
entity.VerifyGetterSetter(a => a.Replaces, "working fine");

var r = new Order.Payload();
r.VerifyGetterSetter(a => a.Csr, "certes is working");
Expand Down
3 changes: 2 additions & 1 deletion test/Certes.Tests/Helper.v2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
6 changes: 3 additions & 3 deletions test/Certes.Tests/IAcmeContextExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ public async Task CanGetTos()
var tosUri = new Uri("http://acme.d/tos");
var ctxMock = new Mock<IAcmeContext>();
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());
}
}
Expand Down
59 changes: 59 additions & 0 deletions test/Certes.Tests/Sample/example.pfx.base64
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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
Tg+HxoFD5KsvH08ECHbGXILWosnfAgIIAA==
Loading