diff --git a/.golangci.yml b/.golangci.yml index dd07c95b..f8259a09 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 00000000..7a5c5985 --- /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 b37fa07d..d91edbe7 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 00000000..26160672 --- /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/certificate/renewal_test.go b/certificate/renewal_test.go new file mode 100644 index 00000000..5d5e0f25 --- /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/cmd/cmd_renew.go b/cmd/cmd_renew.go index e3310ccc..87da699e 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 diff --git a/platform/tester/api.go b/platform/tester/api.go index 97942750..175530f9 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) {