Skip to content

Commit f0b76a1

Browse files
awlyChris Palmer
andauthored
acme: implement ACME Renewal Info (ARI) extension (#10)
Co-authored-by: Chris Palmer <[email protected]>
1 parent 5bb7951 commit f0b76a1

File tree

3 files changed

+247
-7
lines changed

3 files changed

+247
-7
lines changed

acme/acme.go

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,14 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) {
179179
c.addNonce(res.Header)
180180

181181
var v struct {
182-
Reg string `json:"newAccount"`
183-
Authz string `json:"newAuthz"`
184-
Order string `json:"newOrder"`
185-
Revoke string `json:"revokeCert"`
186-
Nonce string `json:"newNonce"`
187-
KeyChange string `json:"keyChange"`
188-
Meta struct {
182+
Reg string `json:"newAccount"`
183+
Authz string `json:"newAuthz"`
184+
Order string `json:"newOrder"`
185+
Revoke string `json:"revokeCert"`
186+
Nonce string `json:"newNonce"`
187+
KeyChange string `json:"keyChange"`
188+
RenewalInfo string `json:"renewalInfo"`
189+
Meta struct {
189190
Terms string `json:"termsOfService"`
190191
Website string `json:"website"`
191192
CAA []string `json:"caaIdentities"`
@@ -205,6 +206,7 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) {
205206
RevokeURL: v.Revoke,
206207
NonceURL: v.Nonce,
207208
KeyChangeURL: v.KeyChange,
209+
RenewalInfoURL: v.RenewalInfo,
208210
Terms: v.Meta.Terms,
209211
Website: v.Meta.Website,
210212
CAA: v.Meta.CAA,
@@ -257,6 +259,86 @@ func (c *Client) RevokeCert(ctx context.Context, key crypto.Signer, cert []byte,
257259
return c.revokeCertRFC(ctx, key, cert, reason)
258260
}
259261

262+
// FetchRenewalInfo retrieves the RenewalInfo from Directory.RenewalInfoURL.
263+
func (c *Client) FetchRenewalInfo(ctx context.Context, leaf, issuer []byte) (*RenewalInfo, error) {
264+
if _, err := c.Discover(ctx); err != nil {
265+
return nil, err
266+
}
267+
268+
parsedLeaf, err := x509.ParseCertificate(leaf)
269+
if err != nil {
270+
return nil, fmt.Errorf("parsing leaf certificate: %w", err)
271+
}
272+
parsedIssuer, err := x509.ParseCertificate(issuer)
273+
if err != nil {
274+
return nil, fmt.Errorf("parsing issuer certificate: %w", err)
275+
}
276+
277+
renewalURL, err := c.getRenewalURL(parsedLeaf, parsedIssuer)
278+
if err != nil {
279+
return nil, fmt.Errorf("generating renewal info URL: %w", err)
280+
}
281+
282+
res, err := c.get(ctx, renewalURL, wantStatus(http.StatusOK))
283+
if err != nil {
284+
return nil, fmt.Errorf("fetching renewal info: %w", err)
285+
}
286+
defer res.Body.Close()
287+
288+
var info RenewalInfo
289+
if err := json.NewDecoder(res.Body).Decode(&info); err != nil {
290+
return nil, fmt.Errorf("parsing renewal info response: %w", err)
291+
}
292+
return &info, nil
293+
}
294+
295+
func (c *Client) getRenewalURL(cert, issuer *x509.Certificate) (string, error) {
296+
// See https://www.ietf.org/archive/id/draft-ietf-acme-ari-01.html#name-getting-renewal-information
297+
// for how the request URL is built.
298+
var publicKeyInfo struct {
299+
Algorithm pkix.AlgorithmIdentifier
300+
PublicKey asn1.BitString
301+
}
302+
if _, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &publicKeyInfo); err != nil {
303+
return "", fmt.Errorf("parsing RawSubjectPublicKeyInfo of the issuer certificate: %w", err)
304+
}
305+
306+
h := crypto.SHA256.New()
307+
h.Write(publicKeyInfo.PublicKey.RightAlign())
308+
issuerKeyHash := h.Sum(nil)
309+
310+
h.Reset()
311+
h.Write(issuer.RawSubject)
312+
issuerNameHash := h.Sum(nil)
313+
314+
// CertID ASN1 structure defined in
315+
// https://datatracker.ietf.org/doc/html/rfc6960#section-4.1.1
316+
certID, err := asn1.Marshal(struct {
317+
HashAlgorithm pkix.AlgorithmIdentifier
318+
NameHash []byte
319+
IssuerKeyHash []byte
320+
SerialNumber *big.Int
321+
}{
322+
pkix.AlgorithmIdentifier{
323+
// SHA256 OID
324+
Algorithm: asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1}),
325+
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
326+
},
327+
issuerNameHash,
328+
issuerKeyHash,
329+
cert.SerialNumber,
330+
})
331+
if err != nil {
332+
return "", fmt.Errorf("marshaling CertID: %w", err)
333+
}
334+
335+
url := c.dir.RenewalInfoURL
336+
if !strings.HasSuffix(url, "/") {
337+
url += "/"
338+
}
339+
return url + base64.RawURLEncoding.EncodeToString(certID), nil
340+
}
341+
260342
// AcceptTOS always returns true to indicate the acceptance of a CA's Terms of Service
261343
// during account registration. See Register method of Client for more details.
262344
func AcceptTOS(tosURL string) bool { return true }

acme/acme_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import (
1515
"encoding/base64"
1616
"encoding/hex"
1717
"encoding/json"
18+
"encoding/pem"
1819
"fmt"
1920
"io"
2021
"math/big"
2122
"net/http"
2223
"net/http/httptest"
24+
"net/url"
2325
"reflect"
2426
"sort"
2527
"strings"
@@ -37,6 +39,17 @@ func newTestClient() *Client {
3739
}
3840
}
3941

42+
// newTestClientWithMockDirectory creates a client with a non-nil Directory
43+
// that contains mock field values.
44+
func newTestClientWithMockDirectory() *Client {
45+
return &Client{
46+
Key: testKeyEC,
47+
dir: &Directory{
48+
RenewalInfoURL: "https://example.com/acme/renewal-info/",
49+
},
50+
}
51+
}
52+
4053
// Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided
4154
// interface.
4255
func decodeJWSRequest(t *testing.T, v interface{}, r io.Reader) {
@@ -497,6 +510,133 @@ func TestFetchCertSize(t *testing.T) {
497510
}
498511
}
499512

513+
const (
514+
issuerPEM = `-----BEGIN CERTIFICATE-----
515+
MIIE3DCCA0SgAwIBAgIRAPoe8bsoe0klnS+2X8jSXe0wDQYJKoZIhvcNAQELBQAw
516+
gYUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEtMCsGA1UECwwkY3Bh
517+
bG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMTQwMgYDVQQDDCtta2Nl
518+
cnQgY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMB4XDTIzMDcx
519+
MjE4MjIxNloXDTMzMDcxMjE4MjIxNlowgYUxHjAcBgNVBAoTFW1rY2VydCBkZXZl
520+
bG9wbWVudCBDQTEtMCsGA1UECwwkY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJp
521+
cyBQYWxtZXIpMTQwMgYDVQQDDCtta2NlcnQgY3BhbG1lckBwdW1wa2luLmxvY2Fs
522+
IChDaHJpcyBQYWxtZXIpMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA
523+
vsqsjjsfOwfwHJO9/st4+bA5Y05puXzjiX+B586Zm3nneQpxTb35vTA7hUn5kT9h
524+
+AlEfOvs1t17NNvQ0NjDXID5xSTfzBU/STAG4gKCGkzJPma++TWM+dlRaL7ZICvE
525+
qigVtbZeCZbu56j0kaZ9eYZyvS1itkTIhN/67qsh7j7BlDhLR1m7jQNz7QaNtLkJ
526+
8NJzKUVmpFHssLBBHkQSWpC7deJczcwZvBI7WbjJyz5xt+gw6sPvNtzGzu+jRmjD
527+
6GtQFbAcV7OTkUDIaxiiO8d5MPqYFTTntPH0Tj/JwEmbUteICYe7aH7Oq/aYWD2I
528+
407ymNjOh1YVHZuOaZVMgw2bhzLWnQYQtO2fTxQud+ppd7T4RFvirYD4Nv/TGjtx
529+
M3YidhioHgd1i41BfSaq+g/QjBljJRygWJo+HX4xRHS3FZvMLtC2/drxVETZyWYj
530+
YVOK+BTteZf5xOSlVqSZ0I1lF0GEiglPrz7ki0zcOL5H8J4V+kKSE+3oIhM/dvG1
531+
AgMBAAGjRTBDMA4GA1UdDwEB/wQEAwICBDASBgNVHRMBAf8ECDAGAQH/AgEAMB0G
532+
A1UdDgQWBBT6S+ENDu2e76E5I59q6xQrH7PE2zANBgkqhkiG9w0BAQsFAAOCAYEA
533+
dZJMBDtrgdTnV4r4XxPwjShFcGxnEHVRbKOixw6euVvfHutCyKljlwQAwKhTJ9iM
534+
ua48h72jlWtgAXDLDXCV7SSYilGhBGECEubxxDGE/b9TBxHediopxQp9wogeUhmV
535+
9BXw0ppJbH1CLmL5bfTR7cJZVz6M8XuqSzTayxuUImcoUNO7dNV0Q5igWRb8vUUK
536+
ITX9tA54qOF3ENQLmeouDdtdJJLI2ExUoqO8XEKwMFg+Pj4AVu2kyzziCCela2ji
537+
TUNcLW0ri2wwY8cc+IsF40tUjcMKlHp1NHVlawgP4wKW7YlEOweGLUFFKTxvTlSZ
538+
gQDZANpuJL7Wqrmu8edffCOnMVxGrSLm6HuVc/RembdguWOPgKb8QImpJQcYv+RD
539+
1KZpqFsCEAED46v7Ea5jrSsyJ/ZysvMC8RfYS55wMTwfaZyVldFW9U3ElzoaWsei
540+
ip2IXMXY/9RjRwc4RGEJcMyIGKXRUat9blzBtv/pNv1uChG2GDCbhltCyz3v5Tn/
541+
-----END CERTIFICATE-----`
542+
leafPEM = `-----BEGIN CERTIFICATE-----
543+
MIIEizCCAvOgAwIBAgIRAITApw7R8HSs7GU7cj8dEyUwDQYJKoZIhvcNAQELBQAw
544+
gYUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEtMCsGA1UECwwkY3Bh
545+
bG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMTQwMgYDVQQDDCtta2Nl
546+
cnQgY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMB4XDTIzMDcx
547+
MjE4MjIxNloXDTI1MTAxMjE4MjIxNlowWDEnMCUGA1UEChMebWtjZXJ0IGRldmVs
548+
b3BtZW50IGNlcnRpZmljYXRlMS0wKwYDVQQLDCRjcGFsbWVyQHB1bXBraW4ubG9j
549+
YWwgKENocmlzIFBhbG1lcikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
550+
AQDNDO8P4MI9jaqVcPtF8C4GgHnTP5EK3U9fgyGApKGxTpicMQkA6z4GXwUP/Fvq
551+
7RuCU9Wg7By5VetKIHF7FxkxWkUMrssr7mV8v6mRCh/a5GqDs14aj5ucjLQAJV74
552+
tLAdrCiijQ1fkPWc82fob+LkfKWGCWw7Cxf6ZtEyC8jz/DnfQXUvOiZS729ndGF7
553+
FobKRfIoirD+GI2NTYIp3LAUFSPR6HXTe7HAg8J81VoUKli8z504+FebfMmHePm/
554+
zIfiI0njAj4czOlZD56/oLsV0WRUizFjafHHUFz1HVdfFw8Qf9IOOTydYOe8M5i0
555+
lVbVO5G+HP+JDn3cr9MT41B9AgMBAAGjgaEwgZ4wDgYDVR0PAQH/BAQDAgWgMBMG
556+
A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFPpL4Q0O7Z7voTkjn2rrFCsf
557+
s8TbMFYGA1UdEQRPME2CC2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29tggxleGFt
558+
cGxlLnRlc3SCCWxvY2FsaG9zdIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkq
559+
hkiG9w0BAQsFAAOCAYEAMlOb7lrHuSxwcnAu7mL1ysTGqKn1d2TyDJAN5W8YFY+4
560+
XLpofNkK2UzZ0t9LQRnuFUcjmfqmfplh5lpC7pKmtL4G5Qcdc+BczQWcopbxd728
561+
sht9BKRkH+Bo1I+1WayKKNXW+5bsMv4CH641zxaMBlzjEnPvwKkNaGLMH3x5lIeX
562+
GGgkKNXwVtINmyV+lTNVtu2IlHprxJGCjRfEuX7mEv6uRnqz3Wif+vgyh3MBgM/1
563+
dUOsTBNH4a6Jl/9VPSOfRdQOStqIlwTa/J1bhTvivsYt1+eWjLnsQJLgZQqwKvYH
564+
BJ30gAk1oNnuSkx9dHbx4mO+4mB9oIYUALXUYakb8JHTOnuMSj9qelVj5vjVxl9q
565+
KRitptU+kLYRA4HSgUXrhDIm4Q6D/w8/ascPqQ3HxPIDFLe+gTofEjqnnsnQB29L
566+
gWpI8l5/MtXAOMdW69eEovnADc2pgaiif0T+v9nNKBc5xfDZHnrnqIqVzQEwL5Qv
567+
niQI8IsWD5LcQ1Eg7kCq
568+
-----END CERTIFICATE-----`
569+
)
570+
571+
func TestGetRenewalURL(t *testing.T) {
572+
leaf, _ := pem.Decode([]byte(leafPEM))
573+
issuer, _ := pem.Decode([]byte(issuerPEM))
574+
575+
parsedLeaf, err := x509.ParseCertificate(leaf.Bytes)
576+
if err != nil {
577+
t.Fatal(err)
578+
}
579+
parsedIssuer, err := x509.ParseCertificate(issuer.Bytes)
580+
if err != nil {
581+
t.Fatal(err)
582+
}
583+
584+
client := newTestClientWithMockDirectory()
585+
urlString, err := client.getRenewalURL(parsedLeaf, parsedIssuer)
586+
if err != nil {
587+
t.Fatal(err)
588+
}
589+
590+
parsedURL, err := url.Parse(urlString)
591+
if err != nil {
592+
t.Fatal(err)
593+
}
594+
if scheme := parsedURL.Scheme; scheme == "" {
595+
t.Fatalf("malformed URL scheme: %q from %q", scheme, urlString)
596+
}
597+
if host := parsedURL.Host; host == "" {
598+
t.Fatalf("malformed URL host: %q from %q", host, urlString)
599+
}
600+
if parsedURL.RawQuery != "" {
601+
t.Fatalf("malformed URL: should not have a query")
602+
}
603+
path := parsedURL.EscapedPath()
604+
slash := strings.LastIndex(path, "/")
605+
if slash == -1 {
606+
t.Fatalf("malformed URL path: %q from %q", path, urlString)
607+
}
608+
certIDPart := path[slash+1:]
609+
if certIDPart == "" {
610+
t.Fatalf("missing certID part in URL path: %q from %q", path, urlString)
611+
}
612+
}
613+
614+
func TestUnmarshalRenewalInfo(t *testing.T) {
615+
renewalInfoJSON := `{
616+
"suggestedWindow": {
617+
"start": "2021-01-03T00:00:00Z",
618+
"end": "2021-01-07T00:00:00Z"
619+
},
620+
"explanationURL": "https://example.com/docs/example-mass-reissuance-event"
621+
}`
622+
expectedStart := time.Date(2021, time.January, 3, 0, 0, 0, 0, time.UTC)
623+
expectedEnd := time.Date(2021, time.January, 7, 0, 0, 0, 0, time.UTC)
624+
625+
var info RenewalInfo
626+
if err := json.Unmarshal([]byte(renewalInfoJSON), &info); err != nil {
627+
t.Fatal(err)
628+
}
629+
if _, err := url.Parse(info.ExplanationURL); err != nil {
630+
t.Fatal(err)
631+
}
632+
if !info.SuggestedWindow.Start.Equal(expectedStart) {
633+
t.Fatalf("%v != %v", expectedStart, info.SuggestedWindow.Start)
634+
}
635+
if !info.SuggestedWindow.End.Equal(expectedEnd) {
636+
t.Fatalf("%v != %v", expectedEnd, info.SuggestedWindow.End)
637+
}
638+
}
639+
500640
func TestNonce_add(t *testing.T) {
501641
var c Client
502642
c.addNonce(http.Header{"Replay-Nonce": {"nonce"}})

acme/types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@ type Directory struct {
288288
// KeyChangeURL allows to perform account key rollover flow.
289289
KeyChangeURL string
290290

291+
// RenewalInfoURL allows to perform certificate renewal using the ACME
292+
// Renewal Information (ARI) Extension.
293+
RenewalInfoURL string
294+
291295
// Term is a URI identifying the current terms of service.
292296
Terms string
293297

@@ -612,3 +616,17 @@ func WithTemplate(t *x509.Certificate) CertOption {
612616
type certOptTemplate x509.Certificate
613617

614618
func (*certOptTemplate) privateCertOpt() {}
619+
620+
// RenewalInfoWindow describes the time frame during which the ACME client
621+
// should attempt to renew, using the ACME Renewal Info Extension.
622+
type RenewalInfoWindow struct {
623+
Start time.Time `json:"start"`
624+
End time.Time `json:"end"`
625+
}
626+
627+
// RenewalInfo describes the suggested renewal window for a given certificate,
628+
// returned from an ACME server, using the ACME Renewal Info Extension.
629+
type RenewalInfo struct {
630+
SuggestedWindow RenewalInfoWindow `json:"suggestedWindow"`
631+
ExplanationURL string `json:"explanationURL"`
632+
}

0 commit comments

Comments
 (0)