From 4a6e757b244f8efe07589b9e71832b75fa562600 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Mon, 23 Feb 2026 20:04:01 +0100 Subject: [PATCH] feat: change CertificateService and GetRenewalInfo signatures (#2870) --- acme/api/certificate.go | 6 +- acme/api/certificate_renewal.go | 92 +++++++++++ acme/api/certificate_renewal_test.go | 171 ++++++++++++++++++++ acme/api/certificate_test.go | 12 +- acme/api/renewal.go | 34 ---- acme/commons.go | 30 ++-- certificate/certificates.go | 8 +- certificate/renewal.go | 81 ++-------- certificate/renewal_test.go | 223 +++++---------------------- cmd/cmd_renew.go | 5 +- 10 files changed, 338 insertions(+), 324 deletions(-) create mode 100644 acme/api/certificate_renewal.go create mode 100644 acme/api/certificate_renewal_test.go delete mode 100644 acme/api/renewal.go diff --git a/acme/api/certificate.go b/acme/api/certificate.go index 6fcef0384..449c931dc 100644 --- a/acme/api/certificate.go +++ b/acme/api/certificate.go @@ -18,13 +18,13 @@ type CertificateService service // Get Returns the certificate and the issuer certificate. // 'bundle' is only applied if the issuer is provided by the 'up' link. -func (c *CertificateService) Get(ctx context.Context, certURL string, bundle bool) ([]byte, []byte, error) { +func (c *CertificateService) Get(ctx context.Context, certURL string, bundle bool) (*acme.RawCertificate, error) { cert, _, err := c.get(ctx, certURL, bundle) if err != nil { - return nil, nil, err + return nil, err } - return cert.Cert, cert.Issuer, nil + return cert, nil } // GetAll the certificates and the alternate certificates. diff --git a/acme/api/certificate_renewal.go b/acme/api/certificate_renewal.go new file mode 100644 index 000000000..57d2049ef --- /dev/null +++ b/acme/api/certificate_renewal.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/go-acme/lego/v5/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://www.rfc-editor.org/rfc/rfc9773.html +func (c *CertificateService) GetRenewalInfo(ctx context.Context, certID string) (*acme.ExtendedRenewalInfo, error) { + if c.core.GetDirectory().RenewalInfo == "" { + return nil, ErrNoARI + } + + if certID == "" { + return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.core.GetDirectory().RenewalInfo+"/"+certID, nil) + if err != nil { + return nil, err + } + + resp, err := c.core.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + info := new(acme.ExtendedRenewalInfo) + + err = json.NewDecoder(resp.Body).Decode(info) + if err != nil { + return nil, err + } + + if retry := resp.Header.Get("Retry-After"); retry != "" { + info.RetryAfter, err = ParseRetryAfter(retry) + if err != nil { + return nil, fmt.Errorf("failed to parse Retry-After header: %w", err) + } + } + + return info, nil +} + +// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1. +func MakeARICertID(leaf *x509.Certificate) (string, error) { + if leaf == nil { + return "", errors.New("leaf certificate is nil") + } + + // Marshal the Serial Number into DER. + der, err := asn1.Marshal(leaf.SerialNumber) + if err != nil { + return "", err + } + + // Check if the DER encoded bytes are sufficient (at least 3 bytes: tag, + // length, and value). + if len(der) < 3 { + return "", errors.New("invalid DER encoding of serial number") + } + + // Extract only the integer bytes from the DER encoded Serial Number + // Skipping the first 2 bytes (tag and length). + serial := base64.RawURLEncoding.EncodeToString(der[2:]) + + // Convert the Authority Key Identifier to base64url encoding without + // padding. + aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId) + + // Construct the final identifier by concatenating AKI and Serial Number. + return fmt.Sprintf("%s.%s", aki, serial), nil +} diff --git a/acme/api/certificate_renewal_test.go b/acme/api/certificate_renewal_test.go new file mode 100644 index 000000000..7cd02f615 --- /dev/null +++ b/acme/api/certificate_renewal_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "net/http" + "testing" + "time" + + "github.com/go-acme/lego/v5/certcrypto" + "github.com/go-acme/lego/v5/platform/tester" + "github.com/go-acme/lego/v5/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + ariLeafPEM = `-----BEGIN CERTIFICATE----- +MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt +cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS +BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu +7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf +qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B +yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb ++FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK +-----END CERTIFICATE-----` + ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE" +) + +func TestMakeCertID(t *testing.T) { + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + actual, err := MakeARICertID(leaf) + require.NoError(t, err) + assert.Equal(t, ariLeafCertID, actual) +} + +func TestCertificateService_GetRenewalInfo(t *testing.T) { + // small value keeps test fast + privateKey, errK := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, errK, "Could not generate test key") + + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, + servermock.RawStringResponse(`{ + "suggestedWindow": { + "start": "2020-03-17T17:51:09Z", + "end": "2020-03-17T18:21:09Z" + }, + "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" + } + }`). + WithHeader("Content-Type", "application/json"). + WithHeader("Retry-After", "21600")). + BuildHTTPS(t) + + core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey) + require.NoError(t, err) + + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + ri, err := core.Certificates.GetRenewalInfo(t.Context(), mustMakeARICertID(t, leaf)) + 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.example/docs/renewal-advice/", ri.ExplanationURL) + assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter) +} + +func TestCertificateService_GetRenewalInfo_retryAfter(t *testing.T) { + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, + servermock.RawStringResponse(`{ + "suggestedWindow": { + "start": "2020-03-17T17:51:09Z", + "end": "2020-03-17T18:21:09Z" + }, + "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" + } + }`). + WithHeader("Content-Type", "application/json"). + WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))). + BuildHTTPS(t) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) + require.NoError(t, err) + + ri, err := core.Certificates.GetRenewalInfo(t.Context(), mustMakeARICertID(t, leaf)) + 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.example/docs/renewal-advice/", ri.ExplanationURL) + + assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001) +} + +func TestCertificateService_GetRenewalInfo_errors(t *testing.T) { + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + testCases := []struct { + desc string + timeout time.Duration + request string + handler http.HandlerFunc + }{ + { + desc: "API timeout", + timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms. + request: mustMakeARICertID(t, leaf), + handler: func(w http.ResponseWriter, r *http.Request) { + // API that takes 2ms to respond. + time.Sleep(2 * time.Millisecond) + }, + }, + { + desc: "API error", + request: mustMakeARICertID(t, leaf), + 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) + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, test.handler). + BuildHTTPS(t) + + client := server.Client() + + if test.timeout != 0 { + client.Timeout = test.timeout + } + + core, err := New(client, "lego-test", server.URL+"/dir", "", key) + require.NoError(t, err) + + response, err := core.Certificates.GetRenewalInfo(t.Context(), test.request) + require.Error(t, err) + assert.Nil(t, response) + }) + } +} + +func mustMakeARICertID(t *testing.T, leaf *x509.Certificate) string { + t.Helper() + + certID, err := MakeARICertID(leaf) + require.NoError(t, err) + + return certID +} diff --git a/acme/api/certificate_test.go b/acme/api/certificate_test.go index 9419bca74..ea4dd9d09 100644 --- a/acme/api/certificate_test.go +++ b/acme/api/certificate_test.go @@ -83,10 +83,10 @@ func TestCertificateService_Get_issuerRelUp(t *testing.T) { core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) - cert, issuer, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true) + rawCert, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true) require.NoError(t, err) - assert.Equal(t, certResponseMock, string(cert), "Certificate") - assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") + assert.Equal(t, certResponseMock, string(rawCert.Cert), "Certificate") + assert.Equal(t, issuerMock, string(rawCert.Issuer), "IssuerCertificate") } func TestCertificateService_Get_embeddedIssuer(t *testing.T) { @@ -100,8 +100,8 @@ func TestCertificateService_Get_embeddedIssuer(t *testing.T) { core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key) require.NoError(t, err) - cert, issuer, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true) + rawCert, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true) require.NoError(t, err) - assert.Equal(t, certResponseMock, string(cert), "Certificate") - assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate") + assert.Equal(t, certResponseMock, string(rawCert.Cert), "Certificate") + assert.Equal(t, issuerMock, string(rawCert.Issuer), "IssuerCertificate") } diff --git a/acme/api/renewal.go b/acme/api/renewal.go deleted file mode 100644 index 2e4da6140..000000000 --- a/acme/api/renewal.go +++ /dev/null @@ -1,34 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" -) - -// 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://www.rfc-editor.org/rfc/rfc9773.html -func (c *CertificateService) GetRenewalInfo(ctx context.Context, 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") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.core.GetDirectory().RenewalInfo+"/"+certID, nil) - if err != nil { - return nil, err - } - - return c.core.HTTPClient.Do(req) -} diff --git a/acme/commons.go b/acme/commons.go index 0af623e4e..e520299d8 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -350,9 +350,19 @@ type Window struct { End time.Time `json:"end"` } -// RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint. -// - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html -type RenewalInfoResponse struct { +type ExtendedRenewalInfo struct { + RenewalInfo + + // RetryAfter header indicating the polling interval that the ACME server recommends. + // Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed, + // as the server may provide a different suggestedWindow. + // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 + RetryAfter time.Duration +} + +// RenewalInfo is the response to GET requests made the renewalInfo endpoint. +// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 +type RenewalInfo 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"` @@ -362,17 +372,3 @@ type RenewalInfoResponse struct { // 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. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 -type RenewalInfoUpdateRequest struct { - // CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the - // certificate's authority key identifier and Serial is the certificate's serial number. For details, see: - // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1 - 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/certificates.go b/certificate/certificates.go index b4ac143da..0f7d9756f 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -674,13 +674,13 @@ func (c *Certifier) GetOCSP(ctx context.Context, bundle []byte) ([]byte, *ocsp.R // // If bundle is true, the Certificate field in the returned Resource includes the issuer certificate. func (c *Certifier) Get(ctx context.Context, url string, bundle bool) (*Resource, error) { - cert, issuer, err := c.core.Certificates.Get(ctx, url, bundle) + rawCert, err := c.core.Certificates.Get(ctx, url, bundle) if err != nil { return nil, err } // Parse the returned cert bundle so that we can grab the domain from the common name. - x509Certs, err := certcrypto.ParsePEMBundle(cert) + x509Certs, err := certcrypto.ParsePEMBundle(rawCert.Cert) if err != nil { return nil, err } @@ -693,8 +693,8 @@ func (c *Certifier) Get(ctx context.Context, url string, bundle bool) (*Resource return &Resource{ ID: domain, Domains: certcrypto.ExtractDomains(x509Certs[0]), - Certificate: cert, - IssuerCertificate: issuer, + Certificate: rawCert.Cert, + IssuerCertificate: rawCert.Issuer, CertURL: url, CertStableURL: url, }, nil diff --git a/certificate/renewal.go b/certificate/renewal.go index 444a6c507..f85781e90 100644 --- a/certificate/renewal.go +++ b/certificate/renewal.go @@ -3,10 +3,6 @@ package certificate import ( "context" "crypto/x509" - "encoding/asn1" - "encoding/base64" - "encoding/json" - "errors" "fmt" "math/rand" "time" @@ -15,28 +11,18 @@ import ( "github.com/go-acme/lego/v5/acme/api" ) -// RenewalInfoRequest contains the necessary renewal information. -type RenewalInfoRequest struct { - Cert *x509.Certificate +// RenewalInfo is a wrapper around acme.ExtendedRenewalInfo that provides a method for determining when to renew a certificate. +type RenewalInfo struct { + *acme.ExtendedRenewalInfo } -// RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate. -type RenewalInfoResponse struct { - acme.RenewalInfoResponse - - // RetryAfter header indicating the polling interval that the ACME server recommends. - // Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed, - // as the server may provide a different suggestedWindow. - // https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2 - RetryAfter time.Duration -} - -// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep. +// 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 RFC 9773. // // - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html -func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time { +func (r *RenewalInfo) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time { // Explicitly convert all times to UTC. now = now.UTC() start := r.SuggestedWindow.Start.UTC() @@ -68,67 +54,22 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D // 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. +// The caller should attempt to renew the certificate at the time indicated by the RenewalInfo.ShouldRenewAt method. // // 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://www.rfc-editor.org/rfc/rfc9773.html -func (c *Certifier) GetRenewalInfo(ctx context.Context, req RenewalInfoRequest) (*RenewalInfoResponse, error) { - certID, err := MakeARICertID(req.Cert) +func (c *Certifier) GetRenewalInfo(ctx context.Context, cert *x509.Certificate) (*RenewalInfo, error) { + certID, err := api.MakeARICertID(cert) if err != nil { return nil, fmt.Errorf("error making certID: %w", err) } - resp, err := c.core.Certificates.GetRenewalInfo(ctx, certID) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var info RenewalInfoResponse - - err = json.NewDecoder(resp.Body).Decode(&info) + info, err := c.core.Certificates.GetRenewalInfo(ctx, certID) if err != nil { return nil, err } - if retry := resp.Header.Get("Retry-After"); retry != "" { - info.RetryAfter, err = api.ParseRetryAfter(retry) - if err != nil { - return nil, fmt.Errorf("failed to parse Retry-After header: %w", err) - } - } - - return &info, nil -} - -// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1. -func MakeARICertID(leaf *x509.Certificate) (string, error) { - if leaf == nil { - return "", errors.New("leaf certificate is nil") - } - - // Marshal the Serial Number into DER. - der, err := asn1.Marshal(leaf.SerialNumber) - if err != nil { - return "", err - } - - // Check if the DER encoded bytes are sufficient (at least 3 bytes: tag, - // length, and value). - if len(der) < 3 { - return "", errors.New("invalid DER encoding of serial number") - } - - // Extract only the integer bytes from the DER encoded Serial Number - // Skipping the first 2 bytes (tag and length). - serial := base64.RawURLEncoding.EncodeToString(der[2:]) - - // Convert the Authority Key Identifier to base64url encoding without - // padding. - aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId) - - // Construct the final identifier by concatenating AKI and Serial Number. - return fmt.Sprintf("%s.%s", aki, serial), nil + return &RenewalInfo{ExtendedRenewalInfo: info}, err } diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go index 0b183f57d..cc9d07ee4 100644 --- a/certificate/renewal_test.go +++ b/certificate/renewal_test.go @@ -1,186 +1,29 @@ package certificate import ( - "crypto/rand" - "crypto/rsa" - "net/http" "testing" "time" "github.com/go-acme/lego/v5/acme" - "github.com/go-acme/lego/v5/acme/api" - "github.com/go-acme/lego/v5/certcrypto" - "github.com/go-acme/lego/v5/platform/tester" - "github.com/go-acme/lego/v5/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const ( - ariLeafPEM = `-----BEGIN CERTIFICATE----- -MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt -cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS -BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu -7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf -qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B -yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb -+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK ------END CERTIFICATE-----` - ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE" -) - -func Test_makeCertID(t *testing.T) { - leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) - require.NoError(t, err) - - actual, err := MakeARICertID(leaf) - 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) - - // Test with a fake API. - server := tester.MockACMEServer(). - Route("GET /renewalInfo/"+ariLeafCertID, - servermock.RawStringResponse(`{ - "suggestedWindow": { - "start": "2020-03-17T17:51:09Z", - "end": "2020-03-17T18:21:09Z" - }, - "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" - } - }`). - WithHeader("Content-Type", "application/json"). - WithHeader("Retry-After", "21600")). - BuildHTTPS(t) - - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err, "Could not generate test key") - - core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) - require.NoError(t, err) - - certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - - ri, err := certifier.GetRenewalInfo(t.Context(), RenewalInfoRequest{leaf}) - 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.example/docs/renewal-advice/", ri.ExplanationURL) - assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter) -} - -func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) { - leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) - require.NoError(t, err) - - server := tester.MockACMEServer(). - Route("GET /renewalInfo/"+ariLeafCertID, - servermock.RawStringResponse(`{ - "suggestedWindow": { - "start": "2020-03-17T17:51:09Z", - "end": "2020-03-17T18:21:09Z" - }, - "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" - } - }`). - WithHeader("Content-Type", "application/json"). - WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))). - BuildHTTPS(t) - - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err, "Could not generate test key") - - core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) - require.NoError(t, err) - - certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - - ri, err := certifier.GetRenewalInfo(t.Context(), RenewalInfoRequest{leaf}) - 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.example/docs/renewal-advice/", ri.ExplanationURL) - - assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001) -} - -func TestCertifier_GetRenewalInfo_errors(t *testing.T) { - leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) - require.NoError(t, err) - - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err, "Could not generate test key") - - testCases := []struct { - desc string - timeout time.Duration - request RenewalInfoRequest - handler http.HandlerFunc - }{ - { - desc: "API timeout", - timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms. - request: RenewalInfoRequest{leaf}, - handler: func(w http.ResponseWriter, r *http.Request) { - // API that takes 2ms to respond. - time.Sleep(2 * time.Millisecond) - }, - }, - { - desc: "API error", - request: RenewalInfoRequest{leaf}, - 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) - }, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - server := tester.MockACMEServer(). - Route("GET /renewalInfo/"+ariLeafCertID, test.handler). - BuildHTTPS(t) - - client := server.Client() - - if test.timeout != 0 { - client.Timeout = test.timeout - } - - core, err := api.New(client, "lego-test", server.URL+"/dir", "", key) - require.NoError(t, err) - - certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - - response, err := certifier.GetRenewalInfo(t.Context(), test.request) - require.Error(t, err) - assert.Nil(t, response) - }) - } -} - func TestRenewalInfoResponse_ShouldRenew(t *testing.T) { now := time.Now().UTC() t.Run("Window is in the past", func(t *testing.T) { - ri := RenewalInfoResponse{ - RenewalInfoResponse: acme.RenewalInfoResponse{ - SuggestedWindow: acme.Window{ - Start: now.Add(-2 * time.Hour), - End: now.Add(-1 * time.Hour), + ri := RenewalInfo{ + ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{ + RenewalInfo: acme.RenewalInfo{ + SuggestedWindow: acme.Window{ + Start: now.Add(-2 * time.Hour), + End: now.Add(-1 * time.Hour), + }, + ExplanationURL: "", }, - ExplanationURL: "", + RetryAfter: 0, }, - RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 0) @@ -189,15 +32,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) { }) t.Run("Window is in the future", func(t *testing.T) { - ri := RenewalInfoResponse{ - RenewalInfoResponse: acme.RenewalInfoResponse{ - SuggestedWindow: acme.Window{ - Start: now.Add(1 * time.Hour), - End: now.Add(2 * time.Hour), + ri := RenewalInfo{ + ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{ + RenewalInfo: acme.RenewalInfo{ + SuggestedWindow: acme.Window{ + Start: now.Add(1 * time.Hour), + End: now.Add(2 * time.Hour), + }, + ExplanationURL: "", }, - ExplanationURL: "", + RetryAfter: 0, }, - RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 0) @@ -205,15 +50,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) { }) t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) { - ri := RenewalInfoResponse{ - RenewalInfoResponse: acme.RenewalInfoResponse{ - SuggestedWindow: acme.Window{ - Start: now.Add(1 * time.Hour), - End: now.Add(2 * time.Hour), + ri := RenewalInfo{ + ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{ + RenewalInfo: acme.RenewalInfo{ + SuggestedWindow: acme.Window{ + Start: now.Add(1 * time.Hour), + End: now.Add(2 * time.Hour), + }, + ExplanationURL: "", }, - ExplanationURL: "", + RetryAfter: 0, }, - RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 2*time.Hour) @@ -222,15 +69,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) { }) t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) { - ri := RenewalInfoResponse{ - RenewalInfoResponse: acme.RenewalInfoResponse{ - SuggestedWindow: acme.Window{ - Start: now.Add(1 * time.Hour), - End: now.Add(2 * time.Hour), + ri := RenewalInfo{ + ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{ + RenewalInfo: acme.RenewalInfo{ + SuggestedWindow: acme.Window{ + Start: now.Add(1 * time.Hour), + End: now.Add(2 * time.Hour), + }, + ExplanationURL: "", }, - ExplanationURL: "", + RetryAfter: 0, }, - RetryAfter: 0, } rt := ri.ShouldRenewAt(now, 59*time.Minute) diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index b0142960c..076e2d83f 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -17,7 +17,6 @@ import ( "github.com/go-acme/lego/v5/acme/api" "github.com/go-acme/lego/v5/certcrypto" - "github.com/go-acme/lego/v5/certificate" "github.com/go-acme/lego/v5/cmd/internal/hook" "github.com/go-acme/lego/v5/cmd/internal/storage" "github.com/go-acme/lego/v5/lego" @@ -354,7 +353,7 @@ func getARIInfo(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certI } } - replacesCertID, err := certificate.MakeARICertID(cert) + replacesCertID, err := api.MakeARICertID(cert) if err != nil { return nil, "", fmt.Errorf("error while constructing the ARI CertID for domain %q: %w", certID, err) } @@ -364,7 +363,7 @@ func getARIInfo(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certI // getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint. func getARIRenewalTime(ctx context.Context, willingToSleep time.Duration, cert *x509.Certificate, certID string, client *lego.Client) *time.Time { - renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, certificate.RenewalInfoRequest{Cert: cert}) + renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, cert) if err != nil { if errors.Is(err, api.ErrNoARI) { log.Warn("acme: the server does not advertise a renewal info endpoint.",