From 3c73f624babb09c86eb504ec0f545ecaddfe7255 Mon Sep 17 00:00:00 2001 From: Samantha Date: Wed, 24 Jan 2024 16:02:50 -0500 Subject: [PATCH] feat: update CertID format as per draft-ietf-acme-ari-02 (#2066) --- .golangci.yml | 2 +- README.md | 2 +- acme/commons.go | 6 ++- certificate/renewal.go | 104 ++++-------------------------------- certificate/renewal_test.go | 57 +++----------------- cmd/cmd_renew.go | 41 +++----------- docs/data/zz_cli_help.toml | 3 +- 7 files changed, 33 insertions(+), 182 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 03bf1f99..c00b6c4a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -236,4 +236,4 @@ issues: - path: providers/dns/hosttech/internal/client_test.go text: 'Duplicate words \(0\) found' - path: cmd/cmd_renew.go - text: 'cyclomatic complexity 16 of func `renewForDomains` is high' + text: 'cyclomatic complexity 15 of func `renewForDomains` is high' diff --git a/README.md b/README.md index d0df0bec..213ae89b 100644 --- a/README.md +++ b/README.md @@ -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-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension + - Support [draft-ietf-acme-ari-02](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 diff --git a/acme/commons.go b/acme/commons.go index 03b5db6d..70b2783d 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -329,9 +329,11 @@ type RenewalInfoResponse struct { } // RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint. -// - (4.2. Updating Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ +// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.2 type RenewalInfoUpdateRequest struct { - // CertID is the base64url-encoded [RFC4648] bytes of a DER-encoded CertID ASN.1 sequence [RFC6960] with any trailing '=' characters stripped. + // 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 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, diff --git a/certificate/renewal.go b/certificate/renewal.go index 26160672..314cd3ea 100644 --- a/certificate/renewal.go +++ b/certificate/renewal.go @@ -1,14 +1,11 @@ package certificate import ( - "crypto" "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" "encoding/base64" "encoding/json" + "errors" "fmt" - "math/big" "math/rand" "strings" "time" @@ -18,11 +15,7 @@ import ( // RenewalInfoRequest contains the necessary renewal information. type RenewalInfoRequest struct { - Cert *x509.Certificate - Issuer *x509.Certificate - // HashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package (e.g. "SHA-256"). - // The correct value depends on the algorithm expected by the ACME server's ARI implementation. - HashName string + Cert *x509.Certificate } // RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate. @@ -72,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 := makeCertID(req.Cert, req.Issuer, req.HashName) + certID, err := makeARICertID(req.Cert) if err != nil { return nil, fmt.Errorf("error making certID: %w", err) } @@ -100,7 +93,7 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse // // https://datatracker.ietf.org/doc/draft-ietf-acme-ari func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error { - certID, err := makeCertID(req.Cert, req.Issuer, req.HashName) + certID, err := makeARICertID(req.Cert) if err != nil { return fmt.Errorf("error making certID: %w", err) } @@ -116,89 +109,14 @@ func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error { return nil } -// makeCertID returns a base64url-encoded string that uniquely identifies a certificate to endpoints -// that implement the draft-ietf-acme-ari specification: https://datatracker.ietf.org/doc/draft-ietf-acme-ari. -// hashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package. -// Supported hash functions are SHA-1, SHA-256, SHA-384, and SHA-512. -func makeCertID(leaf, issuer *x509.Certificate, hashName string) (string, error) { +// 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 "", fmt.Errorf("leaf certificate is nil") - } - if issuer == nil { - return "", fmt.Errorf("issuer certificate is nil") + return "", errors.New("leaf certificate is nil") } - var hashFunc crypto.Hash - var oid asn1.ObjectIdentifier - - switch hashName { - // The following correlation of hashFunc to OID is copied from a private mapping in golang.org/x/crypto/ocsp: - // https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.8.0:ocsp/ocsp.go;l=156 - case crypto.SHA1.String(): - hashFunc = crypto.SHA1 - oid = asn1.ObjectIdentifier([]int{1, 3, 14, 3, 2, 26}) - - case crypto.SHA256.String(): - hashFunc = crypto.SHA256 - oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1}) - - case crypto.SHA384.String(): - hashFunc = crypto.SHA384 - oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 2}) - - case crypto.SHA512.String(): - hashFunc = crypto.SHA512 - oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 3}) - - default: - return "", fmt.Errorf("hashName %q is not supported by this package", hashName) - } - - if !hashFunc.Available() { - // This should never happen. - return "", fmt.Errorf("hash function %q is not available on your platform", hashFunc) - } - - var spki struct { - Algorithm pkix.AlgorithmIdentifier - PublicKey asn1.BitString - } - - _, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &spki) - if err != nil { - return "", err - } - h := hashFunc.New() - h.Write(spki.PublicKey.RightAlign()) - issuerKeyHash := h.Sum(nil) - - h.Reset() - h.Write(issuer.RawSubject) - issuerNameHash := h.Sum(nil) - - type certID struct { - HashAlgorithm pkix.AlgorithmIdentifier - IssuerNameHash []byte - IssuerKeyHash []byte - SerialNumber *big.Int - } - - // DER-encode the CertID ASN.1 sequence [RFC6960]. - certIDBytes, err := asn1.Marshal(certID{ - HashAlgorithm: pkix.AlgorithmIdentifier{ - Algorithm: oid, - }, - IssuerNameHash: issuerNameHash, - IssuerKeyHash: issuerKeyHash, - SerialNumber: leaf.SerialNumber, - }) - if err != nil { - return "", err - } - - // base64url-encode [RFC4648] the bytes of the DER-encoded CertID ASN.1 sequence [RFC6960]. - encodedBytes := base64.URLEncoding.EncodeToString(certIDBytes) - - // Any trailing '=' characters MUST be stripped. - return strings.TrimRight(encodedBytes, "="), nil + return fmt.Sprintf("%s.%s", + strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.AuthorityKeyId), "="), + strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.SerialNumber.Bytes()), "="), + ), nil } diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go index 5d5e0f25..5f501d63 100644 --- a/certificate/renewal_test.go +++ b/certificate/renewal_test.go @@ -1,7 +1,6 @@ package certificate import ( - "crypto" "crypto/rand" "crypto/rsa" "encoding/json" @@ -40,36 +39,14 @@ TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq o9q12g== -----END CERTIFICATE-----` - ariIssuerPEM = `-----BEGIN CERTIFICATE----- -MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE -AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw -MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T -IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6 -TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3 -zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg -c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA -kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB -AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr -BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql -STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq -hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid -1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W -ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg -Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc -fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ -QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ== ------END CERTIFICATE-----` - ariLeafCertID = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c" + ariLeafCertID = "OM8w0VGlx1SqpUk1pFCxlOMxmaU.PqNFaGVEHxw" ) func Test_makeCertID(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) - issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM)) - require.NoError(t, err) - actual, err := makeCertID(leaf, issuer, crypto.SHA256.String()) + actual, err := makeARICertID(leaf) require.NoError(t, err) assert.Equal(t, ariLeafCertID, actual) } @@ -77,8 +54,6 @@ func Test_makeCertID(t *testing.T) { func TestCertifier_GetRenewalInfo(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) - issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM)) - require.NoError(t, err) // Test with a fake API. mux, apiURL := tester.SetupFakeAPI(t) @@ -109,7 +84,7 @@ func TestCertifier_GetRenewalInfo(t *testing.T) { certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()}) + ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf}) require.NoError(t, err) require.NotNil(t, ri) assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) @@ -120,8 +95,6 @@ func TestCertifier_GetRenewalInfo(t *testing.T) { func TestCertifier_GetRenewalInfo_errors(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) - issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM)) - require.NoError(t, err) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") @@ -135,7 +108,7 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { { desc: "API timeout", httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms. - request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()}, + request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that takes 2ms to respond. time.Sleep(2 * time.Millisecond) @@ -144,20 +117,12 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { { desc: "API error", httpClient: http.DefaultClient, - request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()}, + request: RenewalInfoRequest{leaf}, handler: func(w http.ResponseWriter, r *http.Request) { // API that responds with error instead of renewal info. http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) }, }, - { - desc: "Issuer certificate is nil", - httpClient: http.DefaultClient, - request: RenewalInfoRequest{leaf, nil, crypto.SHA256.String()}, - handler: func(w http.ResponseWriter, r *http.Request) { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - }, - }, } for _, test := range testCases { @@ -183,8 +148,6 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) { func TestCertifier_UpdateRenewalInfo(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) - issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM)) - require.NoError(t, err) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") @@ -217,15 +180,13 @@ func TestCertifier_UpdateRenewalInfo(t *testing.T) { certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()}) + 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) - issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM)) - require.NoError(t, err) key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Could not generate test key") @@ -236,11 +197,7 @@ func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) { }{ { desc: "API error", - request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()}, - }, - { - desc: "Certificate is nil", - request: RenewalInfoRequest{nil, issuer, crypto.SHA256.String()}, + request: RenewalInfoRequest{leaf}, }, } diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index 70848af6..68bb2bd8 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -53,11 +53,6 @@ func createRenew() *cli.Command { Name: "ari-enable", Usage: "Use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed.", }, - &cli.StringFlag{ - Name: "ari-hash-name", - Value: crypto.SHA256.String(), - Usage: "The string representation of the hash expected by the renewalInfo endpoint (e.g. \"SHA-256\").", - }, &cli.DurationFlag{ Name: "ari-wait-to-renew-duration", Usage: "The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint.", @@ -146,11 +141,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif var ariRenewalTime *time.Time if ctx.Bool("ari-enable") { - if len(certificates) < 2 { - log.Warnf("[%s] Certificate bundle does not contain issuer, cannot use the renewalInfo endpoint", domain) - } else { - ariRenewalTime = getARIRenewalTime(ctx, certificates[0], certificates[1], domain, client) - } + ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() // Figure out if we need to sleep before renewing. @@ -216,11 +207,7 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif if ariRenewalTime != nil { // Post to the renewalInfo endpoint to indicate that we have renewed and replaced the certificate. - err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{ - Cert: certificates[0], - Issuer: certificates[1], - HashName: ctx.String("ari-hash-name"), - }) + err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{Cert: certificates[0]}) if err != nil { log.Warnf("[%s] Failed to update renewal info: %v", domain, err) } @@ -255,11 +242,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat var ariRenewalTime *time.Time if ctx.Bool("ari-enable") { - if len(certificates) < 2 { - log.Warnf("[%s] Certificate bundle does not contain issuer, cannot use the renewalInfo endpoint", domain) - } else { - ariRenewalTime = getARIRenewalTime(ctx, certificates[0], certificates[1], domain, client) - } + ariRenewalTime = getARIRenewalTime(ctx, cert, domain, client) if ariRenewalTime != nil { now := time.Now().UTC() // Figure out if we need to sleep before renewing. @@ -296,11 +279,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat if ariRenewalTime != nil { // Post to the renewalInfo endpoint to indicate that we have renewed and replaced the certificate. - err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{ - Cert: certificates[0], - Issuer: certificates[1], - HashName: ctx.String("ari-hash-name"), - }) + err := client.Certificate.UpdateRenewalInfo(certificate.RenewalInfoRequest{Cert: certificates[0]}) if err != nil { log.Warnf("[%s] Failed to update renewal info: %v", domain, err) } @@ -331,23 +310,19 @@ func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool { } // getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint. -func getARIRenewalTime(ctx *cli.Context, cert, issuer *x509.Certificate, domain string, client *lego.Client) *time.Time { +func getARIRenewalTime(ctx *cli.Context, cert *x509.Certificate, domain string, client *lego.Client) *time.Time { if cert.IsCA { log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain) } - renewalInfo, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{ - Cert: cert, - Issuer: issuer, - HashName: ctx.String("ari-hash-name"), - }) + renewalInfo, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: cert}) if err != nil { if errors.Is(err, api.ErrNoARI) { // The server does not advertise a renewal info endpoint. - log.Warnf("[%s] acme: %w", domain, err) + log.Warnf("[%s] acme: %v", domain, err) return nil } - log.Warnf("[%s] acme: calling renewal info endpoint: %w", domain, err) + log.Warnf("[%s] acme: calling renewal info endpoint: %v", domain, err) return nil } diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index b6fd50c0..e3b598b0 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -82,8 +82,7 @@ USAGE: OPTIONS: --days value The number of days left on a certificate to renew it. (default: 0) - --ari-enable Use the renewalInfo endpoint (draft-ietf-acme-ari) to check if a certificate should be renewed. (default: false) - --ari-hash-name value The string representation of the hash expected by the renewalInfo endpoint (e.g. "SHA-256"). + --ari-enable Use the renewalInfo endpoint (draft-ietf-acme-ari-02) to check if a certificate should be renewed. (default: false) --ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s) --reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false) --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. (default: false)