Repository: go-acme/lego (language: go)

Issue:
Support ACME Renewal Information
### Welcome

- [X] Yes, I've searched similar issues on GitHub and didn't find any.

### How do you use lego?

Binary

### Detailed Description

While it is still [a draft specification](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/), Let's Encrypt has [announced](https://letsencrypt.org/2023/03/23/improving-resliiency-and-reliability-with-ari.html) production deployment of ARI. This extension allows ACME providers to suggest a renewal window.

If you're willing to implement the draft version (and I'm not necessarily saying you should), this could be implemented quite easily as a better default than "thirty days before the certificate expires". Of course, it may be best to make this opt-in until the IETF actually adopts it as an RFC, even if Let's Encrypt feels it is ready.

#### Renewal Behavior

Section 4.1 of the current draft includes the following:

> Conforming clients MUST attempt renewal at a time of their choosing
> based on the suggested renewal window.  The following algorithm is
> RECOMMENDED for choosing a renewal time:
>
> 1.  Select a uniform random time within the suggested window.
>
> 2.  If the selected time is in the past, attempt renewal immediately.
>
> 3.  Otherwise, if the client can schedule itself to attempt renewal
>     at exactly the selected time, do so.
>
> 4.  Otherwise, if the selected time is before the next time that the
>     client would wake up normally, attempt renewal immediately.
>
> 5.  Otherwise, sleep until the next normal wake time, re-check ARI,
>     and return to Step 1.
>
> In all cases, renewal attempts are subject to the client's existing
> error backoff and retry intervals.

Steps 1 and 3 may justify sleeping until the correct time, though that could cause complications if Lego is started again while waiting.

Optionally, to better implement step 4, Lego could accept a parameter describing when it would next be run (I'm specifically thinking of Cron jobs). That could also serve as maximum sleep time for the other steps, since it wouldn't get run again during that period. Whether this is worth bothering with is up to you.

The simplest approach, of course, is to just renew if within or after the window and stop otherwise.


Discussion / hints:
I'm glad to see that an ARI proposal has been submitted already! Over the next week, I'll start working on a pull request to add ARI support in `go-acme/lego`.
> I'm glad to see that an ARI proposal has been submitted already! Over the next week, I'll start working on a pull request to add ARI support in `go-acme/lego`.

I was delayed while starting on this but I've forked the repo and begun work this week.

Fix patch:
diff --git a/.golangci.yml b/.golangci.yml
index dd07c95b04..f8259a0918 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -207,3 +207,6 @@ issues:
       text: 'mu is a global variable'
     - path: providers/dns/hosttech/internal/client_test.go
       text: 'Duplicate words \(0\) found'
+    - path: cmd/cmd_renew.go
+      text: 'cyclomatic complexity 16 of func `renewForDomains` is high'
+
diff --git a/acme/api/renewal.go b/acme/api/renewal.go
new file mode 100644
index 0000000000..7a5c5985c7
--- /dev/null
+++ b/acme/api/renewal.go
@@ -0,0 +1,53 @@
+package api
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/go-acme/lego/v4/acme"
+)
+
+// ErrNoARI is returned when the server does not advertise a renewal info endpoint.
+var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a renewal info endpoint")
+
+// GetRenewalInfo GETs renewal information for a certificate from the renewalInfo endpoint.
+// This is used to determine if a certificate needs to be renewed.
+//
+// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
+// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
+//
+// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+func (c *CertificateService) GetRenewalInfo(certID string) (*http.Response, error) {
+	if c.core.GetDirectory().RenewalInfo == "" {
+		return nil, ErrNoARI
+	}
+
+	if certID == "" {
+		return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty")
+	}
+
+	return c.core.HTTPClient.Get(c.core.GetDirectory().RenewalInfo + "/" + certID)
+}
+
+// UpdateRenewalInfo POSTs updated renewal information for a certificate to the renewalInfo endpoint.
+// This is used to indicate that a certificate has been replaced.
+//
+// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
+// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
+//
+// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+func (c *CertificateService) UpdateRenewalInfo(req acme.RenewalInfoUpdateRequest) (*http.Response, error) {
+	if c.core.GetDirectory().RenewalInfo == "" {
+		return nil, ErrNoARI
+	}
+
+	if req.CertID == "" {
+		return nil, errors.New("renewalInfo[post]: 'certID' cannot be empty")
+	}
+
+	if !req.Replaced {
+		return nil, errors.New("renewalInfo[post]: 'replaced' cannot be false")
+	}
+
+	return c.core.post(c.core.GetDirectory().RenewalInfo, req, nil)
+}
diff --git a/acme/commons.go b/acme/commons.go
index b37fa07d92..d91edbe78a 100644
--- a/acme/commons.go
+++ b/acme/commons.go
@@ -38,6 +38,7 @@ const (
 
 // Directory the ACME directory object.
 // - https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1
+// - https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
 type Directory struct {
 	NewNonceURL   string `json:"newNonce"`
 	NewAccountURL string `json:"newAccount"`
@@ -46,6 +47,7 @@ type Directory struct {
 	RevokeCertURL string `json:"revokeCert"`
 	KeyChangeURL  string `json:"keyChange"`
 	Meta          Meta   `json:"meta"`
+	RenewalInfo   string `json:"renewalInfo"`
 }
 
 // Meta the ACME meta object (related to Directory).
@@ -306,3 +308,34 @@ type RawCertificate struct {
 	Cert   []byte
 	Issuer []byte
 }
+
+// Window is a window of time.
+type Window struct {
+	Start time.Time `json:"start"`
+	End   time.Time `json:"end"`
+}
+
+// RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint.
+// - (4.1. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+type RenewalInfoResponse struct {
+	// SuggestedWindow contains two fields, start and end,
+	// whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate.
+	SuggestedWindow Window `json:"suggestedWindow"`
+	//	ExplanationURL is a optional URL pointing to a page which may explain why the suggested renewal window is what it is.
+	//	For example, it may be a page explaining the CA's dynamic load-balancing strategy,
+	//	or a page documenting which certificates are affected by a mass revocation event.
+	//	Callers SHOULD provide this URL to their operator, if present.
+	ExplanationURL string `json:"explanationUrl"`
+}
+
+// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
+// - (4.2. Updating Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+type RenewalInfoUpdateRequest struct {
+	// CertID is the base64url-encoded [RFC4648] bytes of a DER-encoded CertID ASN.1 sequence [RFC6960] with any trailing '=' characters stripped.
+	CertID string `json:"certID"`
+	// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
+	// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
+	// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
+	// Clients SHOULD NOT send a request where this value is false.
+	Replaced bool `json:"replaced"`
+}
diff --git a/certificate/renewal.go b/certificate/renewal.go
new file mode 100644
index 0000000000..2616067289
--- /dev/null
+++ b/certificate/renewal.go
@@ -0,0 +1,204 @@
+package certificate
+
+import (
+	"crypto"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/asn1"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"math/big"
+	"math/rand"
+	"strings"
+	"time"
+
+	"github.com/go-acme/lego/v4/acme"
+)
+
+// RenewalInfoRequest contains the necessary renewal information.
+type RenewalInfoRequest struct {
+	Cert   *x509.Certificate
+	Issuer *x509.Certificate
+	// HashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package (e.g. "SHA-256").
+	// The correct value depends on the algorithm expected by the ACME server's ARI implementation.
+	HashName string
+}
+
+// RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate.
+type RenewalInfoResponse struct {
+	acme.RenewalInfoResponse
+}
+
+// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep.
+// It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time.
+// This method implements the RECOMMENDED algorithm described in draft-ietf-acme-ari.
+//
+// - (4.1-11. Getting Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
+func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
+	// Explicitly convert all times to UTC.
+	now = now.UTC()
+	start := r.SuggestedWindow.Start.UTC()
+	end := r.SuggestedWindow.End.UTC()
+
+	// Select a uniform random time within the suggested window.
+	window := end.Sub(start)
+	randomDuration := time.Duration(rand.Int63n(int64(window)))
+	rt := start.Add(randomDuration)
+
+	// If the selected time is in the past, attempt renewal immediately.
+	if rt.Before(now) {
+		return &now
+	}
+
+	// Otherwise, if the client can schedule itself to attempt renewal at exactly the selected time, do so.
+	willingToSleepUntil := now.Add(willingToSleep)
+	if willingToSleepUntil.After(rt) || willingToSleepUntil.Equal(rt) {
+		return &rt
+	}
+
+	// TODO: Otherwise, if the selected time is before the next time that the client would wake up normally, attempt renewal immediately.
+
+	// Otherwise, sleep until the next normal wake time, re-check ARI, and return to Step 1.
+	return nil
+}
+
+// GetRenewalInfo sends a request to the ACME server's renewalInfo endpoint to obtain a suggested renewal window.
+// The caller MUST provide the certificate and issuer certificate for the certificate they wish to renew.
+// The caller should attempt to renew the certificate at the time indicated by the ShouldRenewAt method of the returned RenewalInfoResponse object.
+//
+// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
+// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
+//
+// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
+	certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
+	if err != nil {
+		return nil, fmt.Errorf("error making certID: %w", err)
+	}
+
+	resp, err := c.core.Certificates.GetRenewalInfo(certID)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	var info RenewalInfoResponse
+	err = json.NewDecoder(resp.Body).Decode(&info)
+	if err != nil {
+		return nil, err
+	}
+	return &info, nil
+}
+
+// UpdateRenewalInfo sends an update to the ACME server's renewal info endpoint to indicate that the client has successfully replaced a certificate.
+// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
+// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
+//
+// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
+// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
+//
+// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
+func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error {
+	certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
+	if err != nil {
+		return fmt.Errorf("error making certID: %w", err)
+	}
+
+	_, err = c.core.Certificates.UpdateRenewalInfo(acme.RenewalInfoUpdateRequest{
+		CertID:   certID,
+		Replaced: true,
+	})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// makeCertID returns a base64url-encoded string that uniquely identifies a certificate to endpoints
+// that implement the draft-ietf-acme-ari specification: https://datatracker.ietf.org/doc/draft-ietf-acme-ari.
+// hashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package.
+// Supported hash functions are SHA-1, SHA-256, SHA-384, and SHA-512.
+func makeCertID(leaf, issuer *x509.Certificate, hashName string) (string, error) {
+	if leaf == nil {
+		return "", fmt.Errorf("leaf certificate is nil")
+	}
+	if issuer == nil {
+		return "", fmt.Errorf("issuer certificate is nil")
+	}
+
+	var hashFunc crypto.Hash
+	var oid asn1.ObjectIdentifier
+
+	switch hashName {
+	// The following correlation of hashFunc to OID is copied from a private mapping in golang.org/x/crypto/ocsp:
+	// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.8.0:ocsp/ocsp.go;l=156
+	case crypto.SHA1.String():
+		hashFunc = crypto.SHA1
+		oid = asn1.ObjectIdentifier([]int{1, 3, 14, 3, 2, 26})
+
+	case crypto.SHA256.String():
+		hashFunc = crypto.SHA256
+		oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1})
+
+	case crypto.SHA384.String():
+		hashFunc = crypto.SHA384
+		oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 2})
+
+	case crypto.SHA512.String():
+		hashFunc = crypto.SHA512
+		oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 3})
+
+	default:
+		return "", fmt.Errorf("hashName %q is not supported by this package", hashName)
+	}
+
+	if !hashFunc.Available() {
+		// This should never happen.
+		return "", fmt.Errorf("hash function %q is not available on your platform", hashFunc)
+	}
+
+	var spki struct {
+		Algorithm pkix.AlgorithmIdentifier
+		PublicKey asn1.BitString
+	}
+
+	_, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &spki)
+	if err != nil {
+		return "", err
+	}
+	h := hashFunc.New()
+	h.Write(spki.PublicKey.RightAlign())
+	issuerKeyHash := h.Sum(nil)
+
+	h.Reset()
+	h.Write(issuer.RawSubject)
+	issuerNameHash := h.Sum(nil)
+
+	type certID struct {
+		HashAlgorithm  pkix.AlgorithmIdentifier
+		IssuerNameHash []byte
+		IssuerKeyHash  []byte
+		SerialNumber   *big.Int
+	}
+
+	// DER-encode the CertID ASN.1 sequence [RFC6960].
+	certIDBytes, err := asn1.Marshal(certID{
+		HashAlgorithm: pkix.AlgorithmIdentifier{
+			Algorithm: oid,
+		},
+		IssuerNameHash: issuerNameHash,
+		IssuerKeyHash:  issuerKeyHash,
+		SerialNumber:   leaf.SerialNumber,
+	})
+	if err != nil {
+		return "", err
+	}
+
+	// base64url-encode [RFC4648] the bytes of the DER-encoded CertID ASN.1 sequence [RFC6960].
+	encodedBytes := base64.URLEncoding.EncodeToString(certIDBytes)
+
+	// Any trailing '=' characters MUST be stripped.
+	return strings.TrimRight(encodedBytes, "="), nil
+}
diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go
index e3310cccf0..87da699ef1 100644
--- a/cmd/cmd_renew.go
+++ b/cmd/cmd_renew.go
@@ -3,10 +3,12 @@ package cmd
 import (
 	"crypto"
 	"crypto/x509"
+	"errors"
 	"math/rand"
 	"os"
 	"time"
 
+	"github.com/go-acme/lego/v4/acme/api"
 	"github.com/go-acme/lego/v4/certcrypto"
 	"github.com/go-acme/lego/v4/certificate"
 	"github.com/go-acme/lego/v4/lego"
@@ -47,6 +49,19 @@ func createRenew() *cli.Command {
 				Value: 30,
 				Usage: "The number of days left on a certificate to renew it.",
 			},
+			&cli.BoolFlag{
+				Name:  "ari-enable",
+				Usage: "Use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed.",
+			},
+			&cli.StringFlag{
+				Name:  "ari-hash-name",
+				Value: crypto.SHA256.String(),
+				Usage: "The string representation of the hash expected by the renewalInfo endpoint (e.g. \"SHA-256\").",
+			},
+			&cli.DurationFlag{
+				Name:  "ari-wait-to-renew-duration",
+				Usage: "The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint.",
+			},
 			&cli.BoolFlag{
 				Name:  "reuse-key",
 				Usage: "Used to indicate you want to reuse your current private key for the new certificate.",
@@ -119,7 +134,24 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
 
 	cert := certificates[0]
 
-	if !needRenewal(cert, domain, ctx.Int("days")) {
+	var ariRenewalTime *time.Time
+	if ctx.Bool("ari-enable") {
+		if len(certificates) < 2 {
+			log.Warnf("[%s] Certificate bundle does not contain issuer, cannot use the renewalInfo endpoint", domain)
+		} else {
+			ariRenewalTime = getARIRenewalTime(ctx, certificates[0], certificates[1], domain, client)
+		}
+		if ariRenewalTime != nil {
+			now := time.Now().UTC()
+			// Figure out if we need to sleep before renewing.
+			if ariRenewalTime.After(now) {
+				log.Infof("[%s] Sleeping %s until renewal time %s", domain, ariRenewalTime.Sub(now), ariRenewalTime)
+				time.Sleep(ariRenewalTime.Sub(now))
+			}
+		}
+	}
+
+	if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int("days")) {
 		return nil
 	}
 
@@ -169,6 +201,18 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif
 
 	certsStorage.SaveResource(certRes)
 
+	if ariRenewalTime != nil {
+		// Post to the renewalInfo endpoint to indicate that we have renewed and replaced the certificate.
+		err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{
+			Cert:     certificates[0],
+			Issuer:   certificates[1],
+			HashName: ctx.String("ari-hash-name"),
+		})
+		if err != nil {
+			log.Warnf("[%s] Failed to update renewal info: %v", domain, err)
+		}
+	}
+
 	meta[renewEnvCertDomain] = domain
 	meta[renewEnvCertPath] = certsStorage.GetFileName(domain, ".crt")
 	meta[renewEnvCertKeyPath] = certsStorage.GetFileName(domain, ".key")
@@ -196,7 +240,24 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
 
 	cert := certificates[0]
 
-	if !needRenewal(cert, domain, ctx.Int("days")) {
+	var ariRenewalTime *time.Time
+	if ctx.Bool("ari-enable") {
+		if len(certificates) < 2 {
+			log.Warnf("[%s] Certificate bundle does not contain issuer, cannot use the renewalInfo endpoint", domain)
+		} else {
+			ariRenewalTime = getARIRenewalTime(ctx, certificates[0], certificates[1], domain, client)
+		}
+		if ariRenewalTime != nil {
+			now := time.Now().UTC()
+			// Figure out if we need to sleep before renewing.
+			if ariRenewalTime.After(now) {
+				log.Infof("[%s] Sleeping %s until renewal time %s", domain, ariRenewalTime.Sub(now), ariRenewalTime)
+				time.Sleep(ariRenewalTime.Sub(now))
+			}
+		}
+	}
+
+	if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int("days")) {
 		return nil
 	}
 
@@ -216,6 +277,18 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat
 
 	certsStorage.SaveResource(certRes)
 
+	if ariRenewalTime != nil {
+		// Post to the renewalInfo endpoint to indicate that we have renewed and replaced the certificate.
+		err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{
+			Cert:     certificates[0],
+			Issuer:   certificates[1],
+			HashName: ctx.String("ari-hash-name"),
+		})
+		if err != nil {
+			log.Warnf("[%s] Failed to update renewal info: %v", domain, err)
+		}
+	}
+
 	meta[renewEnvCertDomain] = domain
 	meta[renewEnvCertPath] = certsStorage.GetFileName(domain, ".crt")
 	meta[renewEnvCertKeyPath] = certsStorage.GetFileName(domain, ".key")
@@ -240,6 +313,42 @@ func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool {
 	return true
 }
 
+// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.
+func getARIRenewalTime(ctx *cli.Context, cert, issuer *x509.Certificate, domain string, client *lego.Client) *time.Time {
+	if cert.IsCA {
+		log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain)
+	}
+
+	renewalInfo, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{
+		Cert:     cert,
+		Issuer:   issuer,
+		HashName: ctx.String("ari-hash-name"),
+	})
+	if err != nil {
+		if errors.Is(err, api.ErrNoARI) {
+			// The server does not advertise a renewal info endpoint.
+			log.Warnf("[%s] acme: %w", domain, err)
+			return nil
+		}
+		log.Warnf("[%s] acme: calling renewal info endpoint: %w", domain, err)
+		return nil
+	}
+
+	now := time.Now().UTC()
+	renewalTime := renewalInfo.ShouldRenewAt(now, ctx.Duration("ari-wait-to-renew-duration"))
+	if renewalTime == nil {
+		log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is not needed", domain)
+		return nil
+	}
+	log.Infof("[%s] acme: renewalInfo endpoint indicates that renewal is needed", domain)
+
+	if renewalInfo.ExplanationURL != "" {
+		log.Infof("[%s] acme: renewalInfo endpoint provided an explanation: %s", domain, renewalInfo.ExplanationURL)
+	}
+
+	return renewalTime
+}
+
 func merge(prevDomains, nextDomains []string) []string {
 	for _, next := range nextDomains {
 		var found bool


Test patch:
diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go
new file mode 100644
index 0000000000..5d5e0f253d
--- /dev/null
+++ b/certificate/renewal_test.go
@@ -0,0 +1,356 @@
+package certificate
+
+import (
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"io"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/go-acme/lego/v4/acme"
+	"github.com/go-acme/lego/v4/acme/api"
+	"github.com/go-acme/lego/v4/certcrypto"
+	"github.com/go-acme/lego/v4/platform/tester"
+	"github.com/go-jose/go-jose/v3"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	ariLeafPEM = `-----BEGIN CERTIFICATE-----
+MIIDMDCCAhigAwIBAgIIPqNFaGVEHxwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MB4XDTIyMDMxNzE3NTEwOVoXDTI0MDQx
+NjE3NTEwOVowFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCgm9K/c+il2Pf0f8qhgxn9SKqXq88cOm9ov9AVRbPA
+OWAAewqX2yUAwI4LZBGEgzGzTATkiXfoJ3cN3k39cH6tBbb3iSPuEn7OZpIk9D+e
+3Q9/hX+N/jlWkaTB/FNA+7aE5IVWhmdczYilXa10V9r+RcvACJt0gsipBZVJ4jfJ
+HnWJJGRZzzxqG/xkQmpXxZO7nOPFc8SxYKWdfcgp+rjR2ogYhSz7BfKoVakGPbpX
+vZOuT9z4kkHra/WjwlkQhtHoTXdAxH3qC2UjMzO57Tx+otj0CxAv9O7CTJXISywB
+vEVcmTSZkHS3eZtvvIwPx7I30ITRkYk/tLl1MbyB3SiZAgMBAAGjeDB2MA4GA1Ud
+DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
+AQH/BAIwADAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTAWBgNVHREE
+DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAx0aYvmCk7JYGNEXe
++hrOfKawkHYzWvA92cI/Oi6h+oSdHZ2UKzwFNf37cVKZ37FCrrv5pFP/xhhHvrNV
+EnOx4IaF7OrnaTu5miZiUWuvRQP7ZGmGNFYbLTEF6/dj+WqyYdVaWzxRqHFu1ptC
+TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp
+1URpvffLZQ8xPsEgOZyPWOcabTwJrtqBwily+lwPFn2mChUx846LwQfxtsXU/lJg
+HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq
+o9q12g==
+-----END CERTIFICATE-----`
+	ariIssuerPEM = `-----BEGIN CERTIFICATE-----
+MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw
+MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T
+IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6
+TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3
+zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg
+c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA
+kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB
+AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
+BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql
+STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq
+hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid
+1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W
+ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg
+Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc
+fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ
+QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ==
+-----END CERTIFICATE-----`
+	ariLeafCertID = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c"
+)
+
+func Test_makeCertID(t *testing.T) {
+	leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+	require.NoError(t, err)
+	issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
+	require.NoError(t, err)
+
+	actual, err := makeCertID(leaf, issuer, crypto.SHA256.String())
+	require.NoError(t, err)
+	assert.Equal(t, ariLeafCertID, actual)
+}
+
+func TestCertifier_GetRenewalInfo(t *testing.T) {
+	leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+	require.NoError(t, err)
+	issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
+	require.NoError(t, err)
+
+	// Test with a fake API.
+	mux, apiURL := tester.SetupFakeAPI(t)
+	mux.HandleFunc("/renewalInfo/"+ariLeafCertID, func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet {
+			http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		_, wErr := w.Write([]byte(`{
+				"suggestedWindow": {
+					"start": "2020-03-17T17:51:09Z",
+					"end": "2020-03-17T18:21:09Z"
+				},
+				"explanationUrl": "https://aricapable.ca/docs/renewal-advice/"
+			}
+		}`))
+		require.NoError(t, wErr)
+	})
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err, "Could not generate test key")
+
+	core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+	require.NoError(t, err)
+
+	certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+	ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
+	require.NoError(t, err)
+	require.NotNil(t, ri)
+	assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
+	assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
+	assert.Equal(t, "https://aricapable.ca/docs/renewal-advice/", ri.ExplanationURL)
+}
+
+func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
+	leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+	require.NoError(t, err)
+	issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
+	require.NoError(t, err)
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err, "Could not generate test key")
+
+	testCases := []struct {
+		desc       string
+		httpClient *http.Client
+		request    RenewalInfoRequest
+		handler    http.HandlerFunc
+	}{
+		{
+			desc:       "API timeout",
+			httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms.
+			request:    RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				// API that takes 2ms to respond.
+				time.Sleep(2 * time.Millisecond)
+			},
+		},
+		{
+			desc:       "API error",
+			httpClient: http.DefaultClient,
+			request:    RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				// API that responds with error instead of renewal info.
+				http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+			},
+		},
+		{
+			desc:       "Issuer certificate is nil",
+			httpClient: http.DefaultClient,
+			request:    RenewalInfoRequest{leaf, nil, crypto.SHA256.String()},
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			mux, apiURL := tester.SetupFakeAPI(t)
+			mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler)
+
+			core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key)
+			require.NoError(t, err)
+
+			certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+			response, err := certifier.GetRenewalInfo(test.request)
+			require.Error(t, err)
+			assert.Nil(t, response)
+		})
+	}
+}
+
+func TestCertifier_UpdateRenewalInfo(t *testing.T) {
+	leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+	require.NoError(t, err)
+	issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
+	require.NoError(t, err)
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err, "Could not generate test key")
+
+	// Test with a fake API.
+	mux, apiURL := tester.SetupFakeAPI(t)
+	mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+			return
+		}
+
+		body, rsbErr := readSignedBody(r, key)
+		if rsbErr != nil {
+			http.Error(w, rsbErr.Error(), http.StatusBadRequest)
+			return
+		}
+
+		var req acme.RenewalInfoUpdateRequest
+		err = json.Unmarshal(body, &req)
+		assert.NoError(t, err)
+		assert.True(t, req.Replaced)
+		assert.Equal(t, ariLeafCertID, req.CertID)
+
+		w.WriteHeader(http.StatusOK)
+	})
+
+	core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+	require.NoError(t, err)
+
+	certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+	err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
+	require.NoError(t, err)
+}
+
+func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) {
+	leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
+	require.NoError(t, err)
+	issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
+	require.NoError(t, err)
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err, "Could not generate test key")
+
+	testCases := []struct {
+		desc    string
+		request RenewalInfoRequest
+	}{
+		{
+			desc:    "API error",
+			request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
+		},
+		{
+			desc:    "Certificate is nil",
+			request: RenewalInfoRequest{nil, issuer, crypto.SHA256.String()},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			mux, apiURL := tester.SetupFakeAPI(t)
+
+			// Always returns an error.
+			mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
+				http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+			})
+
+			core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
+			require.NoError(t, err)
+
+			certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
+
+			err = certifier.UpdateRenewalInfo(test.request)
+			require.Error(t, err)
+		})
+	}
+}
+
+func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
+	now := time.Now().UTC()
+
+	t.Run("Window is in the past", func(t *testing.T) {
+		ri := RenewalInfoResponse{
+			acme.RenewalInfoResponse{
+				SuggestedWindow: acme.Window{
+					Start: now.Add(-2 * time.Hour),
+					End:   now.Add(-1 * time.Hour),
+				},
+				ExplanationURL: "",
+			},
+		}
+
+		rt := ri.ShouldRenewAt(now, 0)
+		require.NotNil(t, rt)
+		assert.Equal(t, now, *rt)
+	})
+
+	t.Run("Window is in the future", func(t *testing.T) {
+		ri := RenewalInfoResponse{
+			acme.RenewalInfoResponse{
+				SuggestedWindow: acme.Window{
+					Start: now.Add(1 * time.Hour),
+					End:   now.Add(2 * time.Hour),
+				},
+				ExplanationURL: "",
+			},
+		}
+
+		rt := ri.ShouldRenewAt(now, 0)
+		assert.Nil(t, rt)
+	})
+
+	t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) {
+		ri := RenewalInfoResponse{
+			acme.RenewalInfoResponse{
+				SuggestedWindow: acme.Window{
+					Start: now.Add(1 * time.Hour),
+					End:   now.Add(2 * time.Hour),
+				},
+				ExplanationURL: "",
+			},
+		}
+
+		rt := ri.ShouldRenewAt(now, 2*time.Hour)
+		require.NotNil(t, rt)
+		assert.True(t, rt.Before(now.Add(2*time.Hour)))
+	})
+
+	t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) {
+		ri := RenewalInfoResponse{
+			acme.RenewalInfoResponse{
+				SuggestedWindow: acme.Window{
+					Start: now.Add(1 * time.Hour),
+					End:   now.Add(2 * time.Hour),
+				},
+				ExplanationURL: "",
+			},
+		}
+
+		rt := ri.ShouldRenewAt(now, 59*time.Minute)
+		assert.Nil(t, rt)
+	})
+}
+
+func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
+	reqBody, err := io.ReadAll(r.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	jws, err := jose.ParseSigned(string(reqBody))
+	if err != nil {
+		return nil, err
+	}
+
+	body, err := jws.Verify(&jose.JSONWebKey{
+		Key:       privateKey.Public(),
+		Algorithm: "RSA",
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return body, nil
+}
diff --git a/platform/tester/api.go b/platform/tester/api.go
index 97942750e1..175530f960 100644
--- a/platform/tester/api.go
+++ b/platform/tester/api.go
@@ -29,6 +29,7 @@ func SetupFakeAPI(t *testing.T) (*http.ServeMux, string) {
 			NewOrderURL:   server.URL + "/newOrder",
 			RevokeCertURL: server.URL + "/revokeCert",
 			KeyChangeURL:  server.URL + "/keyChange",
+			RenewalInfo:   server.URL + "/renewalInfo",
 		})
 
 		mux.HandleFunc("/nonce", func(w http.ResponseWriter, r *http.Request) {