Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acme: implement ACME Renewal Info (ARI) extension #10

Merged
merged 2 commits into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 89 additions & 7 deletions acme/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
140 changes: 140 additions & 0 deletions acme/acme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -510,6 +523,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"}})
Expand Down
18 changes: 18 additions & 0 deletions acme/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"`
}