From 4618bf5358de0527a8372fc244db223e6ab5eef0 Mon Sep 17 00:00:00 2001 From: Samantha Date: Fri, 16 Feb 2024 11:29:51 -0500 Subject: [PATCH] Add support for newOrder 'replaces' field as per draft-ietf-acme-ari-02 --- acme/api/order.go | 8 ++++ acme/commons.go | 6 +++ certificate/certificates.go | 18 ++++++-- certificate/renewal.go | 31 ++------------ certificate/renewal_test.go | 82 +------------------------------------ cmd/cmd_renew.go | 28 ++++++------- 6 files changed, 44 insertions(+), 129 deletions(-) diff --git a/acme/api/order.go b/acme/api/order.go index fe1be94f76..5179d061a4 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -13,6 +13,10 @@ import ( type OrderOptions struct { NotBefore time.Time NotAfter time.Time + // A string uniquely identifying a previously-issued certificate which this + // order is intended to replace. + // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + ReplacesCertID string } type OrderService service @@ -45,6 +49,10 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm if !opts.NotBefore.IsZero() { orderReq.NotBefore = opts.NotBefore.Format(time.RFC3339) } + + if o.core.GetDirectory().RenewalInfo != "" { + orderReq.Replaces = opts.ReplacesCertID + } } var order acme.Order diff --git a/acme/commons.go b/acme/commons.go index 70b2783d66..6e918caa21 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -181,6 +181,12 @@ type Order struct { // certificate (optional, string): // A URL for the certificate that has been issued in response to this order Certificate string `json:"certificate,omitempty"` + + // replaces (optional, string): + // replaces (string, optional): A string uniquely identifying a + // previously-issued certificate which this order is intended to replace. + // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + Replaces string `json:"replaces,omitempty"` } // Authorization the ACME authorization object. diff --git a/certificate/certificates.go b/certificate/certificates.go index d6a7438cb9..7e69d1f4e8 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -63,6 +63,10 @@ type ObtainRequest struct { Bundle bool PreferredChain string AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this + // order is intended to replace. + // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + ReplacesCertID string } // ObtainForCSRRequest The request to obtain a certificate matching the CSR passed into it. @@ -79,6 +83,10 @@ type ObtainForCSRRequest struct { Bundle bool PreferredChain string AlwaysDeactivateAuthorizations bool + // A string uniquely identifying a previously-issued certificate which this + // order is intended to replace. + // - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5 + ReplacesCertID string } type resolver interface { @@ -124,8 +132,9 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { } orderOpts := &api.OrderOptions{ - NotBefore: request.NotBefore, - NotAfter: request.NotAfter, + NotBefore: request.NotBefore, + NotAfter: request.NotAfter, + ReplacesCertID: request.ReplacesCertID, } order, err := c.core.Orders.NewWithOptions(domains, orderOpts) @@ -189,8 +198,9 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error) } orderOpts := &api.OrderOptions{ - NotBefore: request.NotBefore, - NotAfter: request.NotAfter, + NotBefore: request.NotBefore, + NotAfter: request.NotAfter, + ReplacesCertID: request.ReplacesCertID, } order, err := c.core.Orders.NewWithOptions(domains, orderOpts) diff --git a/certificate/renewal.go b/certificate/renewal.go index 314cd3ea7c..a586909762 100644 --- a/certificate/renewal.go +++ b/certificate/renewal.go @@ -65,7 +65,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D // // https://datatracker.ietf.org/doc/draft-ietf-acme-ari func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) { - certID, err := makeARICertID(req.Cert) + certID, err := MakeARICertID(req.Cert) if err != nil { return nil, fmt.Errorf("error making certID: %w", err) } @@ -84,33 +84,8 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse 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 := makeARICertID(req.Cert) - 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 -} - -// makeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-02, section 4.1. -func makeARICertID(leaf *x509.Certificate) (string, error) { +// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-02, section 4.1. +func MakeARICertID(leaf *x509.Certificate) (string, error) { if leaf == nil { return "", errors.New("leaf certificate is nil") } diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go index 5f501d63f9..9af42c2217 100644 --- a/certificate/renewal_test.go +++ b/certificate/renewal_test.go @@ -3,7 +3,6 @@ package certificate import ( "crypto/rand" "crypto/rsa" - "encoding/json" "io" "net/http" "testing" @@ -46,7 +45,7 @@ func Test_makeCertID(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) - actual, err := makeARICertID(leaf) + actual, err := MakeARICertID(leaf) require.NoError(t, err) assert.Equal(t, ariLeafCertID, actual) } @@ -145,85 +144,6 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { } } -func TestCertifier_UpdateRenewalInfo(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") - - // 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}) - require.NoError(t, err) -} - -func TestCertifier_UpdateRenewalInfo_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 - request RenewalInfoRequest - }{ - { - desc: "API error", - request: RenewalInfoRequest{leaf}, - }, - } - - 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() diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index 0940f580c2..6c0c7853a8 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -187,6 +187,11 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif time.Sleep(sleepTime) } + replacesCertID, err := certificate.MakeARICertID(cert) + if err != nil { + log.Fatalf("Error while construction the ARI CertID for domain %s\n\t%v", domain, err) + } + request := certificate.ObtainRequest{ Domains: merge(certDomains, domains), PrivateKey: privateKey, @@ -196,6 +201,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif Bundle: bundle, PreferredChain: ctx.String("preferred-chain"), AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"), + ReplacesCertID: replacesCertID, } certRes, err := client.Certificate.Obtain(request) @@ -205,14 +211,6 @@ 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]}) - 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") @@ -264,6 +262,11 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) + replacesCertID, err := certificate.MakeARICertID(cert) + if err != nil { + log.Fatalf("Error while construction the ARI CertID for domain %s\n\t%v", domain, err) + } + request := certificate.ObtainForCSRRequest{ CSR: csr, NotBefore: getTime(ctx, "not-before"), @@ -271,6 +274,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat Bundle: bundle, PreferredChain: ctx.String("preferred-chain"), AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"), + ReplacesCertID: replacesCertID, } certRes, err := client.Certificate.ObtainForCSR(request) @@ -280,14 +284,6 @@ 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]}) - 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")