diff --git a/acme/acme.go b/acme/acme.go index aaafea2bc0..b2fd47deb6 100644 --- a/acme/acme.go +++ b/acme/acme.go @@ -179,13 +179,14 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) { c.addNonce(res.Header) var v struct { - Reg string `json:"newAccount"` - Authz string `json:"newAuthz"` - Order string `json:"newOrder"` - Revoke string `json:"revokeCert"` - Nonce string `json:"newNonce"` - KeyChange string `json:"keyChange"` - Meta struct { + Reg string `json:"newAccount"` + Authz string `json:"newAuthz"` + Order string `json:"newOrder"` + Revoke string `json:"revokeCert"` + Nonce string `json:"newNonce"` + KeyChange string `json:"keyChange"` + RenewalInfo string `json:"renewalInfo"` + Meta struct { Terms string `json:"termsOfService"` Website string `json:"website"` CAA []string `json:"caaIdentities"` @@ -205,6 +206,7 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) { RevokeURL: v.Revoke, NonceURL: v.Nonce, KeyChangeURL: v.KeyChange, + RenewalInfoURL: v.RenewalInfo, Terms: v.Meta.Terms, Website: v.Meta.Website, CAA: v.Meta.CAA, @@ -257,6 +259,86 @@ func (c *Client) RevokeCert(ctx context.Context, key crypto.Signer, cert []byte, return c.revokeCertRFC(ctx, key, cert, reason) } +// FetchRenewalInfo retrieves the RenewalInfo from Directory.RenewalInfoURL. +func (c *Client) FetchRenewalInfo(ctx context.Context, leaf, issuer []byte) (*RenewalInfo, error) { + if _, err := c.Discover(ctx); err != nil { + return nil, err + } + + parsedLeaf, err := x509.ParseCertificate(leaf) + if err != nil { + return nil, fmt.Errorf("parsing leaf certificate: %w", err) + } + parsedIssuer, err := x509.ParseCertificate(issuer) + if err != nil { + return nil, fmt.Errorf("parsing issuer certificate: %w", err) + } + + renewalURL, err := c.getRenewalURL(parsedLeaf, parsedIssuer) + if err != nil { + return nil, fmt.Errorf("generating renewal info URL: %w", err) + } + + res, err := c.get(ctx, renewalURL, wantStatus(http.StatusOK)) + if err != nil { + return nil, fmt.Errorf("fetching renewal info: %w", err) + } + defer res.Body.Close() + + var info RenewalInfo + if err := json.NewDecoder(res.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("parsing renewal info response: %w", err) + } + return &info, nil +} + +func (c *Client) getRenewalURL(cert, issuer *x509.Certificate) (string, error) { + // See https://www.ietf.org/archive/id/draft-ietf-acme-ari-01.html#name-getting-renewal-information + // for how the request URL is built. + var publicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString + } + if _, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &publicKeyInfo); err != nil { + return "", fmt.Errorf("parsing RawSubjectPublicKeyInfo of the issuer certificate: %w", err) + } + + h := crypto.SHA256.New() + h.Write(publicKeyInfo.PublicKey.RightAlign()) + issuerKeyHash := h.Sum(nil) + + h.Reset() + h.Write(issuer.RawSubject) + issuerNameHash := h.Sum(nil) + + // CertID ASN1 structure defined in + // https://datatracker.ietf.org/doc/html/rfc6960#section-4.1.1 + certID, err := asn1.Marshal(struct { + HashAlgorithm pkix.AlgorithmIdentifier + NameHash []byte + IssuerKeyHash []byte + SerialNumber *big.Int + }{ + pkix.AlgorithmIdentifier{ + // SHA256 OID + Algorithm: asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1}), + Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, + }, + issuerNameHash, + issuerKeyHash, + cert.SerialNumber, + }) + if err != nil { + return "", fmt.Errorf("marshaling CertID: %w", err) + } + + url := c.dir.RenewalInfoURL + if !strings.HasSuffix(url, "/") { + url += "/" + } + return url + base64.RawURLEncoding.EncodeToString(certID), nil +} + // AcceptTOS always returns true to indicate the acceptance of a CA's Terms of Service // during account registration. See Register method of Client for more details. func AcceptTOS(tosURL string) bool { return true } diff --git a/acme/acme_test.go b/acme/acme_test.go index 3f6e2748f3..f117749a14 100644 --- a/acme/acme_test.go +++ b/acme/acme_test.go @@ -15,11 +15,13 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" "math/big" "net/http" "net/http/httptest" + "net/url" "reflect" "sort" "strings" @@ -37,6 +39,17 @@ func newTestClient() *Client { } } +// newTestClientWithMockDirectory creates a client with a non-nil Directory +// that contains mock field values. +func newTestClientWithMockDirectory() *Client { + return &Client{ + Key: testKeyEC, + dir: &Directory{ + RenewalInfoURL: "https://example.com/acme/renewal-info/", + }, + } +} + // Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided // interface. func decodeJWSRequest(t *testing.T, v interface{}, r io.Reader) { @@ -497,6 +510,133 @@ func TestFetchCertSize(t *testing.T) { } } +const ( + issuerPEM = `-----BEGIN CERTIFICATE----- +MIIE3DCCA0SgAwIBAgIRAPoe8bsoe0klnS+2X8jSXe0wDQYJKoZIhvcNAQELBQAw +gYUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEtMCsGA1UECwwkY3Bh +bG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMTQwMgYDVQQDDCtta2Nl +cnQgY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMB4XDTIzMDcx +MjE4MjIxNloXDTMzMDcxMjE4MjIxNlowgYUxHjAcBgNVBAoTFW1rY2VydCBkZXZl +bG9wbWVudCBDQTEtMCsGA1UECwwkY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJp +cyBQYWxtZXIpMTQwMgYDVQQDDCtta2NlcnQgY3BhbG1lckBwdW1wa2luLmxvY2Fs +IChDaHJpcyBQYWxtZXIpMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA +vsqsjjsfOwfwHJO9/st4+bA5Y05puXzjiX+B586Zm3nneQpxTb35vTA7hUn5kT9h ++AlEfOvs1t17NNvQ0NjDXID5xSTfzBU/STAG4gKCGkzJPma++TWM+dlRaL7ZICvE +qigVtbZeCZbu56j0kaZ9eYZyvS1itkTIhN/67qsh7j7BlDhLR1m7jQNz7QaNtLkJ +8NJzKUVmpFHssLBBHkQSWpC7deJczcwZvBI7WbjJyz5xt+gw6sPvNtzGzu+jRmjD +6GtQFbAcV7OTkUDIaxiiO8d5MPqYFTTntPH0Tj/JwEmbUteICYe7aH7Oq/aYWD2I +407ymNjOh1YVHZuOaZVMgw2bhzLWnQYQtO2fTxQud+ppd7T4RFvirYD4Nv/TGjtx +M3YidhioHgd1i41BfSaq+g/QjBljJRygWJo+HX4xRHS3FZvMLtC2/drxVETZyWYj +YVOK+BTteZf5xOSlVqSZ0I1lF0GEiglPrz7ki0zcOL5H8J4V+kKSE+3oIhM/dvG1 +AgMBAAGjRTBDMA4GA1UdDwEB/wQEAwICBDASBgNVHRMBAf8ECDAGAQH/AgEAMB0G +A1UdDgQWBBT6S+ENDu2e76E5I59q6xQrH7PE2zANBgkqhkiG9w0BAQsFAAOCAYEA +dZJMBDtrgdTnV4r4XxPwjShFcGxnEHVRbKOixw6euVvfHutCyKljlwQAwKhTJ9iM +ua48h72jlWtgAXDLDXCV7SSYilGhBGECEubxxDGE/b9TBxHediopxQp9wogeUhmV +9BXw0ppJbH1CLmL5bfTR7cJZVz6M8XuqSzTayxuUImcoUNO7dNV0Q5igWRb8vUUK +ITX9tA54qOF3ENQLmeouDdtdJJLI2ExUoqO8XEKwMFg+Pj4AVu2kyzziCCela2ji +TUNcLW0ri2wwY8cc+IsF40tUjcMKlHp1NHVlawgP4wKW7YlEOweGLUFFKTxvTlSZ +gQDZANpuJL7Wqrmu8edffCOnMVxGrSLm6HuVc/RembdguWOPgKb8QImpJQcYv+RD +1KZpqFsCEAED46v7Ea5jrSsyJ/ZysvMC8RfYS55wMTwfaZyVldFW9U3ElzoaWsei +ip2IXMXY/9RjRwc4RGEJcMyIGKXRUat9blzBtv/pNv1uChG2GDCbhltCyz3v5Tn/ +-----END CERTIFICATE-----` + leafPEM = `-----BEGIN CERTIFICATE----- +MIIEizCCAvOgAwIBAgIRAITApw7R8HSs7GU7cj8dEyUwDQYJKoZIhvcNAQELBQAw +gYUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEtMCsGA1UECwwkY3Bh +bG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMTQwMgYDVQQDDCtta2Nl +cnQgY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMB4XDTIzMDcx +MjE4MjIxNloXDTI1MTAxMjE4MjIxNlowWDEnMCUGA1UEChMebWtjZXJ0IGRldmVs +b3BtZW50IGNlcnRpZmljYXRlMS0wKwYDVQQLDCRjcGFsbWVyQHB1bXBraW4ubG9j +YWwgKENocmlzIFBhbG1lcikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDNDO8P4MI9jaqVcPtF8C4GgHnTP5EK3U9fgyGApKGxTpicMQkA6z4GXwUP/Fvq +7RuCU9Wg7By5VetKIHF7FxkxWkUMrssr7mV8v6mRCh/a5GqDs14aj5ucjLQAJV74 +tLAdrCiijQ1fkPWc82fob+LkfKWGCWw7Cxf6ZtEyC8jz/DnfQXUvOiZS729ndGF7 +FobKRfIoirD+GI2NTYIp3LAUFSPR6HXTe7HAg8J81VoUKli8z504+FebfMmHePm/ +zIfiI0njAj4czOlZD56/oLsV0WRUizFjafHHUFz1HVdfFw8Qf9IOOTydYOe8M5i0 +lVbVO5G+HP+JDn3cr9MT41B9AgMBAAGjgaEwgZ4wDgYDVR0PAQH/BAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFPpL4Q0O7Z7voTkjn2rrFCsf +s8TbMFYGA1UdEQRPME2CC2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29tggxleGFt +cGxlLnRlc3SCCWxvY2FsaG9zdIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkq +hkiG9w0BAQsFAAOCAYEAMlOb7lrHuSxwcnAu7mL1ysTGqKn1d2TyDJAN5W8YFY+4 +XLpofNkK2UzZ0t9LQRnuFUcjmfqmfplh5lpC7pKmtL4G5Qcdc+BczQWcopbxd728 +sht9BKRkH+Bo1I+1WayKKNXW+5bsMv4CH641zxaMBlzjEnPvwKkNaGLMH3x5lIeX +GGgkKNXwVtINmyV+lTNVtu2IlHprxJGCjRfEuX7mEv6uRnqz3Wif+vgyh3MBgM/1 +dUOsTBNH4a6Jl/9VPSOfRdQOStqIlwTa/J1bhTvivsYt1+eWjLnsQJLgZQqwKvYH +BJ30gAk1oNnuSkx9dHbx4mO+4mB9oIYUALXUYakb8JHTOnuMSj9qelVj5vjVxl9q +KRitptU+kLYRA4HSgUXrhDIm4Q6D/w8/ascPqQ3HxPIDFLe+gTofEjqnnsnQB29L +gWpI8l5/MtXAOMdW69eEovnADc2pgaiif0T+v9nNKBc5xfDZHnrnqIqVzQEwL5Qv +niQI8IsWD5LcQ1Eg7kCq +-----END CERTIFICATE-----` +) + +func TestGetRenewalURL(t *testing.T) { + leaf, _ := pem.Decode([]byte(leafPEM)) + issuer, _ := pem.Decode([]byte(issuerPEM)) + + parsedLeaf, err := x509.ParseCertificate(leaf.Bytes) + if err != nil { + t.Fatal(err) + } + parsedIssuer, err := x509.ParseCertificate(issuer.Bytes) + if err != nil { + t.Fatal(err) + } + + client := newTestClientWithMockDirectory() + urlString, err := client.getRenewalURL(parsedLeaf, parsedIssuer) + if err != nil { + t.Fatal(err) + } + + parsedURL, err := url.Parse(urlString) + if err != nil { + t.Fatal(err) + } + if scheme := parsedURL.Scheme; scheme == "" { + t.Fatalf("malformed URL scheme: %q from %q", scheme, urlString) + } + if host := parsedURL.Host; host == "" { + t.Fatalf("malformed URL host: %q from %q", host, urlString) + } + if parsedURL.RawQuery != "" { + t.Fatalf("malformed URL: should not have a query") + } + path := parsedURL.EscapedPath() + slash := strings.LastIndex(path, "/") + if slash == -1 { + t.Fatalf("malformed URL path: %q from %q", path, urlString) + } + certIDPart := path[slash+1:] + if certIDPart == "" { + t.Fatalf("missing certID part in URL path: %q from %q", path, urlString) + } +} + +func TestUnmarshalRenewalInfo(t *testing.T) { + renewalInfoJSON := `{ + "suggestedWindow": { + "start": "2021-01-03T00:00:00Z", + "end": "2021-01-07T00:00:00Z" + }, + "explanationURL": "https://example.com/docs/example-mass-reissuance-event" + }` + expectedStart := time.Date(2021, time.January, 3, 0, 0, 0, 0, time.UTC) + expectedEnd := time.Date(2021, time.January, 7, 0, 0, 0, 0, time.UTC) + + var info RenewalInfo + if err := json.Unmarshal([]byte(renewalInfoJSON), &info); err != nil { + t.Fatal(err) + } + if _, err := url.Parse(info.ExplanationURL); err != nil { + t.Fatal(err) + } + if !info.SuggestedWindow.Start.Equal(expectedStart) { + t.Fatalf("%v != %v", expectedStart, info.SuggestedWindow.Start) + } + if !info.SuggestedWindow.End.Equal(expectedEnd) { + t.Fatalf("%v != %v", expectedEnd, info.SuggestedWindow.End) + } +} + func TestNonce_add(t *testing.T) { var c Client c.addNonce(http.Header{"Replay-Nonce": {"nonce"}}) diff --git a/acme/types.go b/acme/types.go index 4888726fec..9fad800b4a 100644 --- a/acme/types.go +++ b/acme/types.go @@ -288,6 +288,10 @@ type Directory struct { // KeyChangeURL allows to perform account key rollover flow. KeyChangeURL string + // RenewalInfoURL allows to perform certificate renewal using the ACME + // Renewal Information (ARI) Extension. + RenewalInfoURL string + // Term is a URI identifying the current terms of service. Terms string @@ -612,3 +616,17 @@ func WithTemplate(t *x509.Certificate) CertOption { type certOptTemplate x509.Certificate func (*certOptTemplate) privateCertOpt() {} + +// RenewalInfoWindow describes the time frame during which the ACME client +// should attempt to renew, using the ACME Renewal Info Extension. +type RenewalInfoWindow struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +// RenewalInfo describes the suggested renewal window for a given certificate, +// returned from an ACME server, using the ACME Renewal Info Extension. +type RenewalInfo struct { + SuggestedWindow RenewalInfoWindow `json:"suggestedWindow"` + ExplanationURL string `json:"explanationURL"` +}