mirror of
https://github.com/go-acme/lego
synced 2026-03-14 14:35:48 +01:00
feat: change CertificateService and GetRenewalInfo signatures (#2870)
This commit is contained in:
parent
7d7946d30c
commit
4a6e757b24
10 changed files with 338 additions and 324 deletions
|
|
@ -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.
|
||||
|
|
|
|||
92
acme/api/certificate_renewal.go
Normal file
92
acme/api/certificate_renewal.go
Normal file
|
|
@ -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
|
||||
}
|
||||
171
acme/api/certificate_renewal_test.go
Normal file
171
acme/api/certificate_renewal_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue