Skip to content

Commit

Permalink
feat: implement 'replaces' field in newOrder and draft-ietf-acme-ari-…
Browse files Browse the repository at this point in the history
…03 CertID changes (#2114)
  • Loading branch information
beautifulentropy committed Mar 8, 2024
1 parent adea063 commit 6dd8d03
Show file tree
Hide file tree
Showing 8 changed files with 76 additions and 179 deletions.
2 changes: 1 addition & 1 deletion .golangci.yml
Expand Up @@ -226,6 +226,6 @@ issues:
- path: providers/dns/hosttech/internal/client_test.go
text: 'Duplicate words \(0\) found'
- path: cmd/cmd_renew.go
text: 'cyclomatic complexity 15 of func `renewForDomains` is high'
text: 'cyclomatic complexity \d+ of func `renewForDomains` is high'
- path: providers/dns/cpanel/cpanel.go
text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high'
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -16,7 +16,7 @@ Let's Encrypt client and ACME library written in Go.
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
- Support [draft-ietf-acme-ari-02](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
Expand Down
8 changes: 8 additions & 0 deletions acme/api/order.go
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
10 changes: 8 additions & 2 deletions acme/commons.go
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 Expand Up @@ -329,11 +335,11 @@ type RenewalInfoResponse struct {
}

// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.2
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#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://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#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,
Expand Down
18 changes: 14 additions & 4 deletions certificate/certificates.go
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
53 changes: 23 additions & 30 deletions certificate/renewal.go
Expand Up @@ -2,12 +2,12 @@ package certificate

import (
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/rand"
"strings"
"time"

"github.com/go-acme/lego/v4/acme"
Expand Down 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,39 +84,32 @@ 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)
// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-03, section 4.1.
func MakeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")
}

_, err = c.core.Certificates.UpdateRenewalInfo(acme.RenewalInfoUpdateRequest{
CertID: certID,
Replaced: true,
})
// Marshal the Serial Number into DER.
der, err := asn1.Marshal(leaf.SerialNumber)
if err != nil {
return err
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) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")
// 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")
}

return fmt.Sprintf("%s.%s",
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.AuthorityKeyId), "="),
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.SerialNumber.Bytes()), "="),
), nil
// 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
}
134 changes: 9 additions & 125 deletions certificate/renewal_test.go
Expand Up @@ -3,8 +3,6 @@ package certificate
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"testing"
"time"
Expand All @@ -13,40 +11,28 @@ import (
"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/v4"
"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==
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
-----END CERTIFICATE-----`
ariLeafCertID = "OM8w0VGlx1SqpUk1pFCxlOMxmaU.PqNFaGVEHxw"
ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
)

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 +131,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 Expand Up @@ -289,26 +196,3 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
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
}

sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
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
}

0 comments on commit 6dd8d03

Please sign in to comment.