Skip to content

Commit

Permalink
Add support for newOrder 'replaces' field as per draft-ietf-acme-ari-02
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Feb 16, 2024
1 parent fd6047a commit 4618bf5
Show file tree
Hide file tree
Showing 6 changed files with 44 additions and 129 deletions.
8 changes: 8 additions & 0 deletions acme/api/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions acme/commons.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 14 additions & 4 deletions certificate/certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 3 additions & 28 deletions certificate/renewal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand Down
82 changes: 1 addition & 81 deletions certificate/renewal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package certificate
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"testing"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()

Expand Down
28 changes: 12 additions & 16 deletions cmd/cmd_renew.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -264,13 +262,19 @@ 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"),
NotAfter: getTime(ctx, "not-after"),
Bundle: bundle,
PreferredChain: ctx.String("preferred-chain"),
AlwaysDeactivateAuthorizations: ctx.Bool("always-deactivate-authorizations"),
ReplacesCertID: replacesCertID,
}

certRes, err := client.Certificate.ObtainForCSR(request)
Expand All @@ -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")
Expand Down

0 comments on commit 4618bf5

Please sign in to comment.