Skip to content

Commit 6dd8d03

Browse files
feat: implement 'replaces' field in newOrder and draft-ietf-acme-ari-03 CertID changes (#2114)
1 parent adea063 commit 6dd8d03

File tree

8 files changed

+76
-179
lines changed

8 files changed

+76
-179
lines changed

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,6 @@ issues:
226226
- path: providers/dns/hosttech/internal/client_test.go
227227
text: 'Duplicate words \(0\) found'
228228
- path: cmd/cmd_renew.go
229-
text: 'cyclomatic complexity 15 of func `renewForDomains` is high'
229+
text: 'cyclomatic complexity \d+ of func `renewForDomains` is high'
230230
- path: providers/dns/cpanel/cpanel.go
231231
text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high'

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Let's Encrypt client and ACME library written in Go.
1616
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
1717
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
1818
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
19-
- Support [draft-ietf-acme-ari-02](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
19+
- Support [draft-ietf-acme-ari-03](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
2020
- Register with CA
2121
- Obtain certificates, both from scratch or with an existing CSR
2222
- Renew certificates

acme/api/order.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import (
1313
type OrderOptions struct {
1414
NotBefore time.Time
1515
NotAfter time.Time
16+
// A string uniquely identifying a previously-issued certificate which this
17+
// order is intended to replace.
18+
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
19+
ReplacesCertID string
1620
}
1721

1822
type OrderService service
@@ -45,6 +49,10 @@ func (o *OrderService) NewWithOptions(domains []string, opts *OrderOptions) (acm
4549
if !opts.NotBefore.IsZero() {
4650
orderReq.NotBefore = opts.NotBefore.Format(time.RFC3339)
4751
}
52+
53+
if o.core.GetDirectory().RenewalInfo != "" {
54+
orderReq.Replaces = opts.ReplacesCertID
55+
}
4856
}
4957

5058
var order acme.Order

acme/commons.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ type Order struct {
181181
// certificate (optional, string):
182182
// A URL for the certificate that has been issued in response to this order
183183
Certificate string `json:"certificate,omitempty"`
184+
185+
// replaces (optional, string):
186+
// replaces (string, optional): A string uniquely identifying a
187+
// previously-issued certificate which this order is intended to replace.
188+
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
189+
Replaces string `json:"replaces,omitempty"`
184190
}
185191

186192
// Authorization the ACME authorization object.
@@ -329,11 +335,11 @@ type RenewalInfoResponse struct {
329335
}
330336

331337
// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
332-
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.2
338+
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.2
333339
type RenewalInfoUpdateRequest struct {
334340
// CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the
335341
// certificate's authority key identifier and Serial is the certificate's serial number. For details, see:
336-
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1
342+
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1
337343
CertID string `json:"certID"`
338344
// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
339345
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,

certificate/certificates.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ type ObtainRequest struct {
6363
Bundle bool
6464
PreferredChain string
6565
AlwaysDeactivateAuthorizations bool
66+
// A string uniquely identifying a previously-issued certificate which this
67+
// order is intended to replace.
68+
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
69+
ReplacesCertID string
6670
}
6771

6872
// ObtainForCSRRequest The request to obtain a certificate matching the CSR passed into it.
@@ -79,6 +83,10 @@ type ObtainForCSRRequest struct {
7983
Bundle bool
8084
PreferredChain string
8185
AlwaysDeactivateAuthorizations bool
86+
// A string uniquely identifying a previously-issued certificate which this
87+
// order is intended to replace.
88+
// - https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-5
89+
ReplacesCertID string
8290
}
8391

8492
type resolver interface {
@@ -124,8 +132,9 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) {
124132
}
125133

126134
orderOpts := &api.OrderOptions{
127-
NotBefore: request.NotBefore,
128-
NotAfter: request.NotAfter,
135+
NotBefore: request.NotBefore,
136+
NotAfter: request.NotAfter,
137+
ReplacesCertID: request.ReplacesCertID,
129138
}
130139

131140
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)
@@ -189,8 +198,9 @@ func (c *Certifier) ObtainForCSR(request ObtainForCSRRequest) (*Resource, error)
189198
}
190199

191200
orderOpts := &api.OrderOptions{
192-
NotBefore: request.NotBefore,
193-
NotAfter: request.NotAfter,
201+
NotBefore: request.NotBefore,
202+
NotAfter: request.NotAfter,
203+
ReplacesCertID: request.ReplacesCertID,
194204
}
195205

196206
order, err := c.core.Orders.NewWithOptions(domains, orderOpts)

certificate/renewal.go

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ package certificate
22

33
import (
44
"crypto/x509"
5+
"encoding/asn1"
56
"encoding/base64"
67
"encoding/json"
78
"errors"
89
"fmt"
910
"math/rand"
10-
"strings"
1111
"time"
1212

1313
"github.com/go-acme/lego/v4/acme"
@@ -65,7 +65,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D
6565
//
6666
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
6767
func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
68-
certID, err := makeARICertID(req.Cert)
68+
certID, err := MakeARICertID(req.Cert)
6969
if err != nil {
7070
return nil, fmt.Errorf("error making certID: %w", err)
7171
}
@@ -84,39 +84,32 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse
8484
return &info, nil
8585
}
8686

87-
// UpdateRenewalInfo sends an update to the ACME server's renewal info endpoint to indicate that the client has successfully replaced a certificate.
88-
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
89-
// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
90-
//
91-
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
92-
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
93-
//
94-
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
95-
func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error {
96-
certID, err := makeARICertID(req.Cert)
97-
if err != nil {
98-
return fmt.Errorf("error making certID: %w", err)
87+
// MakeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-03, section 4.1.
88+
func MakeARICertID(leaf *x509.Certificate) (string, error) {
89+
if leaf == nil {
90+
return "", errors.New("leaf certificate is nil")
9991
}
10092

101-
_, err = c.core.Certificates.UpdateRenewalInfo(acme.RenewalInfoUpdateRequest{
102-
CertID: certID,
103-
Replaced: true,
104-
})
93+
// Marshal the Serial Number into DER.
94+
der, err := asn1.Marshal(leaf.SerialNumber)
10595
if err != nil {
106-
return err
96+
return "", err
10797
}
10898

109-
return nil
110-
}
111-
112-
// makeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-02, section 4.1.
113-
func makeARICertID(leaf *x509.Certificate) (string, error) {
114-
if leaf == nil {
115-
return "", errors.New("leaf certificate is nil")
99+
// Check if the DER encoded bytes are sufficient (at least 3 bytes: tag,
100+
// length, and value).
101+
if len(der) < 3 {
102+
return "", errors.New("invalid DER encoding of serial number")
116103
}
117104

118-
return fmt.Sprintf("%s.%s",
119-
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.AuthorityKeyId), "="),
120-
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.SerialNumber.Bytes()), "="),
121-
), nil
105+
// Extract only the integer bytes from the DER encoded Serial Number
106+
// Skipping the first 2 bytes (tag and length).
107+
serial := base64.RawURLEncoding.EncodeToString(der[2:])
108+
109+
// Convert the Authority Key Identifier to base64url encoding without
110+
// padding.
111+
aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId)
112+
113+
// Construct the final identifier by concatenating AKI and Serial Number.
114+
return fmt.Sprintf("%s.%s", aki, serial), nil
122115
}

certificate/renewal_test.go

Lines changed: 9 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ package certificate
33
import (
44
"crypto/rand"
55
"crypto/rsa"
6-
"encoding/json"
7-
"io"
86
"net/http"
97
"testing"
108
"time"
@@ -13,40 +11,28 @@ import (
1311
"github.com/go-acme/lego/v4/acme/api"
1412
"github.com/go-acme/lego/v4/certcrypto"
1513
"github.com/go-acme/lego/v4/platform/tester"
16-
"github.com/go-jose/go-jose/v4"
1714
"github.com/stretchr/testify/assert"
1815
"github.com/stretchr/testify/require"
1916
)
2017

2118
const (
2219
ariLeafPEM = `-----BEGIN CERTIFICATE-----
23-
MIIDMDCCAhigAwIBAgIIPqNFaGVEHxwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
24-
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MB4XDTIyMDMxNzE3NTEwOVoXDTI0MDQx
25-
NjE3NTEwOVowFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEB
26-
AQUAA4IBDwAwggEKAoIBAQCgm9K/c+il2Pf0f8qhgxn9SKqXq88cOm9ov9AVRbPA
27-
OWAAewqX2yUAwI4LZBGEgzGzTATkiXfoJ3cN3k39cH6tBbb3iSPuEn7OZpIk9D+e
28-
3Q9/hX+N/jlWkaTB/FNA+7aE5IVWhmdczYilXa10V9r+RcvACJt0gsipBZVJ4jfJ
29-
HnWJJGRZzzxqG/xkQmpXxZO7nOPFc8SxYKWdfcgp+rjR2ogYhSz7BfKoVakGPbpX
30-
vZOuT9z4kkHra/WjwlkQhtHoTXdAxH3qC2UjMzO57Tx+otj0CxAv9O7CTJXISywB
31-
vEVcmTSZkHS3eZtvvIwPx7I30ITRkYk/tLl1MbyB3SiZAgMBAAGjeDB2MA4GA1Ud
32-
DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
33-
AQH/BAIwADAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTAWBgNVHREE
34-
DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAx0aYvmCk7JYGNEXe
35-
+hrOfKawkHYzWvA92cI/Oi6h+oSdHZ2UKzwFNf37cVKZ37FCrrv5pFP/xhhHvrNV
36-
EnOx4IaF7OrnaTu5miZiUWuvRQP7ZGmGNFYbLTEF6/dj+WqyYdVaWzxRqHFu1ptC
37-
TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp
38-
1URpvffLZQ8xPsEgOZyPWOcabTwJrtqBwily+lwPFn2mChUx846LwQfxtsXU/lJg
39-
HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq
40-
o9q12g==
20+
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
21+
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
22+
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
23+
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
24+
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
25+
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
26+
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
4127
-----END CERTIFICATE-----`
42-
ariLeafCertID = "OM8w0VGlx1SqpUk1pFCxlOMxmaU.PqNFaGVEHxw"
28+
ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
4329
)
4430

4531
func Test_makeCertID(t *testing.T) {
4632
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
4733
require.NoError(t, err)
4834

49-
actual, err := makeARICertID(leaf)
35+
actual, err := MakeARICertID(leaf)
5036
require.NoError(t, err)
5137
assert.Equal(t, ariLeafCertID, actual)
5238
}
@@ -145,85 +131,6 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
145131
}
146132
}
147133

148-
func TestCertifier_UpdateRenewalInfo(t *testing.T) {
149-
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
150-
require.NoError(t, err)
151-
152-
key, err := rsa.GenerateKey(rand.Reader, 2048)
153-
require.NoError(t, err, "Could not generate test key")
154-
155-
// Test with a fake API.
156-
mux, apiURL := tester.SetupFakeAPI(t)
157-
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
158-
if r.Method != http.MethodPost {
159-
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
160-
return
161-
}
162-
163-
body, rsbErr := readSignedBody(r, key)
164-
if rsbErr != nil {
165-
http.Error(w, rsbErr.Error(), http.StatusBadRequest)
166-
return
167-
}
168-
169-
var req acme.RenewalInfoUpdateRequest
170-
err = json.Unmarshal(body, &req)
171-
assert.NoError(t, err)
172-
assert.True(t, req.Replaced)
173-
assert.Equal(t, ariLeafCertID, req.CertID)
174-
175-
w.WriteHeader(http.StatusOK)
176-
})
177-
178-
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
179-
require.NoError(t, err)
180-
181-
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
182-
183-
err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf})
184-
require.NoError(t, err)
185-
}
186-
187-
func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) {
188-
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
189-
require.NoError(t, err)
190-
191-
key, err := rsa.GenerateKey(rand.Reader, 2048)
192-
require.NoError(t, err, "Could not generate test key")
193-
194-
testCases := []struct {
195-
desc string
196-
request RenewalInfoRequest
197-
}{
198-
{
199-
desc: "API error",
200-
request: RenewalInfoRequest{leaf},
201-
},
202-
}
203-
204-
for _, test := range testCases {
205-
test := test
206-
t.Run(test.desc, func(t *testing.T) {
207-
t.Parallel()
208-
209-
mux, apiURL := tester.SetupFakeAPI(t)
210-
211-
// Always returns an error.
212-
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
213-
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
214-
})
215-
216-
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
217-
require.NoError(t, err)
218-
219-
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
220-
221-
err = certifier.UpdateRenewalInfo(test.request)
222-
require.Error(t, err)
223-
})
224-
}
225-
}
226-
227134
func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
228135
now := time.Now().UTC()
229136

@@ -289,26 +196,3 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
289196
assert.Nil(t, rt)
290197
})
291198
}
292-
293-
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
294-
reqBody, err := io.ReadAll(r.Body)
295-
if err != nil {
296-
return nil, err
297-
}
298-
299-
sigAlgs := []jose.SignatureAlgorithm{jose.RS256}
300-
jws, err := jose.ParseSigned(string(reqBody), sigAlgs)
301-
if err != nil {
302-
return nil, err
303-
}
304-
305-
body, err := jws.Verify(&jose.JSONWebKey{
306-
Key: privateKey.Public(),
307-
Algorithm: "RSA",
308-
})
309-
if err != nil {
310-
return nil, err
311-
}
312-
313-
return body, nil
314-
}

0 commit comments

Comments
 (0)