diff --git a/acme/api/order.go b/acme/api/order.go index 4bcc34f4..b71898b1 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -25,41 +25,48 @@ func (o *OrderService) New(domains []string) (acme.ExtendedOrder, error) { } return acme.ExtendedOrder{ - Location: resp.Header.Get("Location"), - Order: order, + Order: order, + Location: resp.Header.Get("Location"), + AlternateChainLinks: getLinks(resp.Header, "alternate"), }, nil } // Get Gets an order. -func (o *OrderService) Get(orderURL string) (acme.Order, error) { +func (o *OrderService) Get(orderURL string) (acme.ExtendedOrder, error) { if len(orderURL) == 0 { - return acme.Order{}, errors.New("order[get]: empty URL") + return acme.ExtendedOrder{}, errors.New("order[get]: empty URL") } var order acme.Order - _, err := o.core.postAsGet(orderURL, &order) + resp, err := o.core.postAsGet(orderURL, &order) if err != nil { - return acme.Order{}, err + return acme.ExtendedOrder{}, err } - return order, nil + return acme.ExtendedOrder{ + Order: order, + AlternateChainLinks: getLinks(resp.Header, "alternate"), + }, nil } // UpdateForCSR Updates an order for a CSR. -func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.Order, error) { +func (o *OrderService) UpdateForCSR(orderURL string, csr []byte) (acme.ExtendedOrder, error) { csrMsg := acme.CSRMessage{ Csr: base64.RawURLEncoding.EncodeToString(csr), } var order acme.Order - _, err := o.core.post(orderURL, csrMsg, &order) + resp, err := o.core.post(orderURL, csrMsg, &order) if err != nil { - return acme.Order{}, err + return acme.ExtendedOrder{}, err } if order.Status == acme.StatusInvalid { - return acme.Order{}, order.Error + return acme.ExtendedOrder{}, order.Error } - return order, nil + return acme.ExtendedOrder{ + Order: order, + AlternateChainLinks: getLinks(resp.Header, "alternate"), + }, nil } diff --git a/acme/api/order_test.go b/acme/api/order_test.go index 072fab0f..4d83d0e1 100644 --- a/acme/api/order_test.go +++ b/acme/api/order_test.go @@ -41,6 +41,10 @@ func TestOrderService_New(t *testing.T) { return } + w.Header().Add("Link", `;rel="alternate"`) + w.Header().Add("Link", `;title="foo";rel="alternate"`) + w.Header().Add("Link", `;title="foo";rel="alternate", ;rel="alternate"`) + err = tester.WriteJSONResponse(w, acme.Order{ Status: acme.StatusValid, Identifiers: order.Identifiers, @@ -62,6 +66,12 @@ func TestOrderService_New(t *testing.T) { Status: "valid", Identifiers: []acme.Identifier{{Type: "dns", Value: "example.com"}}, }, + AlternateChainLinks: []string{ + "https://example.com/acme/cert/1", + "https://example.com/acme/cert/2", + "https://example.com/acme/cert/3", + "https://example.com/acme/cert/4", + }, } assert.Equal(t, expected, order) } diff --git a/acme/api/service.go b/acme/api/service.go index 0644ccad..6f812ee0 100644 --- a/acme/api/service.go +++ b/acme/api/service.go @@ -11,19 +11,30 @@ type service struct { // getLink get a rel into the Link header. func getLink(header http.Header, rel string) string { - linkExpr := regexp.MustCompile(`<(.+?)>;\s*rel="(.+?)"`) + links := getLinks(header, rel) + if len(links) < 1 { + return "" + } + return links[0] +} + +func getLinks(header http.Header, rel string) []string { + linkExpr := regexp.MustCompile(`<(.+?)>(?:;[^;]+)*?;\s*rel="(.+?)"`) + + var links []string for _, link := range header["Link"] { for _, m := range linkExpr.FindAllStringSubmatch(link, -1) { if len(m) != 3 { continue } if m[2] == rel { - return m[1] + links = append(links, m[1]) } } } - return "" + + return links } // getLocation get the value of the header Location. diff --git a/acme/commons.go b/acme/commons.go index 52a991bb..727ef987 100644 --- a/acme/commons.go +++ b/acme/commons.go @@ -108,6 +108,11 @@ type ExtendedOrder struct { Order // The order URL, contains the value of the response header `Location` Location string `json:"-"` + + // AlternateChainLinks (optional, array of string): + // URLs of "alternate" link relation + // - https://tools.ietf.org/html/rfc8555#section-7.4.2 + AlternateChainLinks []string `json:"-"` } // Order the ACME order Object. diff --git a/certificate/certificates.go b/certificate/certificates.go index da79fc90..867e4ac5 100644 --- a/certificate/certificates.go +++ b/certificate/certificates.go @@ -51,10 +51,11 @@ type Resource struct { // // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. type ObtainRequest struct { - Domains []string - Bundle bool - PrivateKey crypto.PrivateKey - MustStaple bool + Domains []string + Bundle bool + PrivateKey crypto.PrivateKey + MustStaple bool + PreferredChain string } type resolver interface { @@ -121,7 +122,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := make(obtainError) - cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple) + cert, err := c.getForOrder(domains, order, request.Bundle, request.PrivateKey, request.MustStaple, request.PreferredChain) if err != nil { for _, auth := range authz { failures[challenge.GetTargetedDomain(auth)] = err @@ -145,7 +146,7 @@ func (c *Certifier) Obtain(request ObtainRequest) (*Resource, error) { // // This function will never return a partial certificate. // If one domain in the list fails, the whole certificate will fail. -func (c *Certifier) ObtainForCSR(csr x509.CertificateRequest, bundle bool) (*Resource, error) { +func (c *Certifier) ObtainForCSR(csr x509.CertificateRequest, bundle bool, preferredChain string) (*Resource, error) { // figure out what domains it concerns // start with the common name domains := certcrypto.ExtractDomainsCSR(&csr) @@ -178,7 +179,7 @@ func (c *Certifier) ObtainForCSR(csr x509.CertificateRequest, bundle bool) (*Res log.Infof("[%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) failures := make(obtainError) - cert, err := c.getForCSR(domains, order, bundle, csr.Raw, nil) + cert, err := c.getForCSR(domains, order, bundle, csr.Raw, nil, preferredChain) if err != nil { for _, auth := range authz { failures[challenge.GetTargetedDomain(auth)] = err @@ -198,7 +199,7 @@ func (c *Certifier) ObtainForCSR(csr x509.CertificateRequest, bundle bool) (*Res return cert, nil } -func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool) (*Resource, error) { +func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bundle bool, privateKey crypto.PrivateKey, mustStaple bool, preferredChain string) (*Resource, error) { if privateKey == nil { var err error privateKey, err = certcrypto.GeneratePrivateKey(c.options.KeyType) @@ -229,10 +230,10 @@ func (c *Certifier) getForOrder(domains []string, order acme.ExtendedOrder, bund return nil, err } - return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey)) + return c.getForCSR(domains, order, bundle, csr, certcrypto.PEMEncode(privateKey), preferredChain) } -func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte) (*Resource, error) { +func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle bool, csr, privateKeyPem []byte, preferredChain string) (*Resource, error) { respOrder, err := c.core.Orders.UpdateForCSR(order.Finalize, csr) if err != nil { return nil, err @@ -247,7 +248,7 @@ func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle if respOrder.Status == acme.StatusValid { // if the certificate is available right away, short cut! - ok, errR := c.checkResponse(respOrder, certRes, bundle) + ok, errR := c.checkResponse(respOrder, certRes, bundle, preferredChain) if errR != nil { return nil, errR } @@ -268,7 +269,7 @@ func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle return false, errW } - done, errW := c.checkResponse(ord, certRes, bundle) + done, errW := c.checkResponse(ord, certRes, bundle, preferredChain) if errW != nil { return false, errW } @@ -287,23 +288,52 @@ func (c *Certifier) getForCSR(domains []string, order acme.ExtendedOrder, bundle // The certRes input should already have the Domain (common name) field populated. // // If bundle is true, the certificate will be bundled with the issuer's cert. -func (c *Certifier) checkResponse(order acme.Order, certRes *Resource, bundle bool) (bool, error) { +func (c *Certifier) checkResponse(order acme.ExtendedOrder, certRes *Resource, bundle bool, preferredChain string) (bool, error) { valid, err := checkOrderStatus(order) if err != nil || !valid { return valid, err } - cert, issuer, err := c.core.Certificates.Get(order.Certificate, bundle) - if err != nil { - return false, err + links := append([]string{order.Certificate}, order.AlternateChainLinks...) + + for i, link := range links { + cert, issuer, err := c.core.Certificates.Get(link, bundle) + if err != nil { + return false, err + } + + // Set the default certificate + if i == 0 { + certRes.IssuerCertificate = issuer + certRes.Certificate = cert + certRes.CertURL = link + certRes.CertStableURL = link + } + + if preferredChain == "" { + log.Infof("[%s] Server responded with a certificate.", certRes.Domain) + + return true, nil + } + + ok, err := hasPreferredChain(issuer, preferredChain) + if err != nil { + return false, err + } + + if ok { + log.Infof("[%s] Server responded with a certificate for the preferred certificate chains %q.", certRes.Domain, preferredChain) + + certRes.IssuerCertificate = issuer + certRes.Certificate = cert + certRes.CertURL = link + certRes.CertStableURL = link + + return true, nil + } } - log.Infof("[%s] Server responded with a certificate.", certRes.Domain) - - certRes.IssuerCertificate = issuer - certRes.Certificate = cert - certRes.CertURL = order.Certificate - certRes.CertStableURL = order.Certificate + log.Infof("lego has been configured to prefer certificate chains with issuer %q, but no chain from the CA matched this issuer. Using the default certificate chain instead.", preferredChain) return true, nil } @@ -337,7 +367,7 @@ func (c *Certifier) Revoke(cert []byte) error { // If bundle is true, the []byte contains both the issuer certificate and your issued certificate as a bundle. // // For private key reuse the PrivateKey property of the passed in Resource should be non-nil. -func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool) (*Resource, error) { +func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool, preferredChain string) (*Resource, error) { // Input certificate is PEM encoded. // Decode it here as we may need the decoded cert later on in the renewal process. // The input may be a bundle or a single certificate. @@ -364,7 +394,7 @@ func (c *Certifier) Renew(certRes Resource, bundle, mustStaple bool) (*Resource, return nil, errP } - return c.ObtainForCSR(*csr, bundle) + return c.ObtainForCSR(*csr, bundle, preferredChain) } var privateKey crypto.PrivateKey @@ -491,7 +521,22 @@ func (c *Certifier) Get(url string, bundle bool) (*Resource, error) { }, nil } -func checkOrderStatus(order acme.Order) (bool, error) { +func hasPreferredChain(issuer []byte, preferredChain string) (bool, error) { + certs, err := certcrypto.ParsePEMBundle(issuer) + if err != nil { + return false, err + } + + for _, cert := range certs { + if cert.Issuer.CommonName == preferredChain { + return true, nil + } + } + + return false, nil +} + +func checkOrderStatus(order acme.ExtendedOrder) (bool, error) { switch order.Status { case acme.StatusValid: return true, nil diff --git a/certificate/certificates_test.go b/certificate/certificates_test.go index 01e9bbf6..4d275cec 100644 --- a/certificate/certificates_test.go +++ b/certificate/certificates_test.go @@ -76,6 +76,82 @@ rzFL1KZfz+HZdnFwFW2T2gVW8L3ii1l9AJDuKzlvjUH3p6bgihVq02sjT8mx+GM2 -----END CERTIFICATE----- ` +const certResponseMock2 = ` +-----BEGIN CERTIFICATE----- +MIIFUzCCBDugAwIBAgISA/z9btaZCSo/qlVwmJrHpoyPMA0GCSqGSIb3DQEBCwUA +MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD +ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDA3MjUwNjUxNDRaFw0y +MDEwMjMwNjUxNDRaMBgxFjAUBgNVBAMTDW5hdHVyZS5nbG9iYWwwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDN/PF8lWub3i+lO3CLl/HJAM86pQH9hWej +Whci1PPNzKyEByJq2psNLCO1W1mXK3ClWSyifptCf7+AAFAOoBojPMwjaKMziw1M +BxAQiX8MzZLv4Hr4Uk08cQX31QHiEpOv4pMHqB0UpodTYY10dZnDdyJHaGKzxfJh +nQPYIVto+UegcVu9iZIDow7ugoT2Gh8nB8jOAc4wtBgmylgeAFmYR6QZ4PYSYFh0 +DLZGGB1WuU/4YC5OciwTDv5EiqP3KM3NdkmGhPY0A3jcTrjN+HhcE4pYBtG1wHi8 +PEuqqKyCLa3AjHq4WrZyCCkCMXPbIDS1Qt7botDmUZr/26xJZnl5AgMBAAGjggJj +MIICXzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF +BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFFm72Cv7LnjVhcLqUujrykUr70lF +MB8GA1UdIwQYMBaAFKhKamMEfd265tE5t6ZFZe/zqOyhMG8GCCsGAQUFBwEBBGMw +YTAuBggrBgEFBQcwAYYiaHR0cDovL29jc3AuaW50LXgzLmxldHNlbmNyeXB0Lm9y +ZzAvBggrBgEFBQcwAoYjaHR0cDovL2NlcnQuaW50LXgzLmxldHNlbmNyeXB0Lm9y +Zy8wGAYDVR0RBBEwD4INbmF0dXJlLmdsb2JhbDBMBgNVHSAERTBDMAgGBmeBDAEC +ATA3BgsrBgEEAYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3BzLmxldHNl +bmNyeXB0Lm9yZzCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB3ALIeBcyLos2KIE6H +ZvkruYolIGdr2vpw57JJUy3vi5BeAAABc4T006IAAAQDAEgwRgIhAPEEvCEMkekD +8XLDaxHPnJ85UZL72JqGgNK+7I/NdFNuAiEA5D78b4V1YsD8wvWz/sk6Ks8VgjED +eKGl/TyXwKEpzEIAdgBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZEwAA +AXOE9NPrAAAEAwBHMEUCIAu4YFfGZIN/P+0eRG0krSddHKCSf6rqr6aVqUWkJY3F +AiEAz0HkTe0alED1gW9nEAJ1qqK1MLMjRM8SsUv9Is86+CwwDQYJKoZIhvcNAQEL +BQADggEBAGriSVi9YuBnm50w84gjlinmeGdvxgugblIoEqKoXd3d5/zx0DvW9Tm6 +YGfXsvAJUSCag7dZ/s/PEu23jKNdFoaBmDaUHHKnUwbWWF7/ptYZ+YuDVGOJo8PL +CULNfUMon20rPU9smzW4BFDBZ6KmX/r4Q8cQ7FLOqKdcng0yMcqIfq4cBxEvd0uQ +pHR3AwCjAIGpV6Q9WHHiHx+SEd/Xc18Z5pXa9m3Rz4i6Mfv+AYLtnsZDxcH81cVM +7rYp80vhXM9tFd4wyrqLuaVZgYD1ylxTYpTI7sijIq4Sl984f3IPA/olN+zK6E8d +EbiufIcKeju/aSellDzzBabEo80YT4o= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow +PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD +Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O +rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq +OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b +xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw +7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD +aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG +SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69 +ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr +AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz +R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5 +JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo +Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ +-----END CERTIFICATE----- +` + +const issuerMock2 = `-----BEGIN CERTIFICATE----- +MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow +PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD +Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O +rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq +OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b +xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw +7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD +aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG +SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69 +ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr +AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz +R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5 +JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo +Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ +-----END CERTIFICATE----- +` + func Test_checkResponse(t *testing.T) { mux, apiURL, tearDown := tester.SetupFakeAPI() defer tearDown() @@ -95,14 +171,16 @@ func Test_checkResponse(t *testing.T) { certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - order := acme.Order{ - Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + order := acme.ExtendedOrder{ + Order: acme.Order{ + Status: acme.StatusValid, + Certificate: apiURL + "/certificate", + }, } certRes := &Resource{} bundle := false - valid, err := certifier.checkResponse(order, certRes, bundle) + valid, err := certifier.checkResponse(order, certRes, bundle, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) @@ -143,14 +221,16 @@ func Test_checkResponse_issuerRelUp(t *testing.T) { certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - order := acme.Order{ - Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + order := acme.ExtendedOrder{ + Order: acme.Order{ + Status: acme.StatusValid, + Certificate: apiURL + "/certificate", + }, } certRes := &Resource{} bundle := false - valid, err := certifier.checkResponse(order, certRes, bundle) + valid, err := certifier.checkResponse(order, certRes, bundle, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) @@ -182,14 +262,16 @@ func Test_checkResponse_embeddedIssuer(t *testing.T) { certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) - order := acme.Order{ - Status: acme.StatusValid, - Certificate: apiURL + "/certificate", + order := acme.ExtendedOrder{ + Order: acme.Order{ + Status: acme.StatusValid, + Certificate: apiURL + "/certificate", + }, } certRes := &Resource{} bundle := false - valid, err := certifier.checkResponse(order, certRes, bundle) + valid, err := certifier.checkResponse(order, certRes, bundle, "") require.NoError(t, err) assert.True(t, valid) assert.NotNil(t, certRes) @@ -202,6 +284,55 @@ func Test_checkResponse_embeddedIssuer(t *testing.T) { assert.Equal(t, issuerMock, string(certRes.IssuerCertificate), "IssuerCertificate") } +func Test_checkResponse_alternate(t *testing.T) { + mux, apiURL, tearDown := tester.SetupFakeAPI() + defer tearDown() + + mux.HandleFunc("/certificate", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(certResponseMock)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + mux.HandleFunc("/certificate2", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(certResponseMock2)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key) + require.NoError(t, err) + + certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) + + order := acme.ExtendedOrder{ + Order: acme.Order{ + Status: acme.StatusValid, + Certificate: apiURL + "/certificate", + }, + AlternateChainLinks: []string{apiURL + "/certificate2"}, + } + certRes := &Resource{} + bundle := false + + valid, err := certifier.checkResponse(order, certRes, bundle, "DST Root CA X3") + require.NoError(t, err) + + assert.True(t, valid) + assert.NotNil(t, certRes) + assert.Equal(t, "", certRes.Domain) + assert.Contains(t, certRes.CertStableURL, "/certificate2") + assert.Contains(t, certRes.CertURL, "/certificate2") + assert.Nil(t, certRes.CSR) + assert.Nil(t, certRes.PrivateKey) + assert.Equal(t, certResponseMock2, string(certRes.Certificate), "Certificate") + assert.Equal(t, issuerMock2, string(certRes.IssuerCertificate), "IssuerCertificate") +} + func Test_Get(t *testing.T) { mux, apiURL, tearDown := tester.SetupFakeAPI() defer tearDown() diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index ee463226..49d024b4 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -58,6 +58,10 @@ func createRenew() cli.Command { Name: "renew-hook", Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.", }, + cli.StringFlag{ + Name: "preferred-chain", + Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.", + }, }, } } @@ -123,10 +127,11 @@ func renewForDomains(ctx *cli.Context, client *lego.Client, certsStorage *Certif } request := certificate.ObtainRequest{ - Domains: merge(certDomains, domains), - Bundle: bundle, - PrivateKey: privateKey, - MustStaple: ctx.Bool("must-staple"), + Domains: merge(certDomains, domains), + Bundle: bundle, + PrivateKey: privateKey, + MustStaple: ctx.Bool("must-staple"), + PreferredChain: ctx.String("preferred-chain"), } certRes, err := client.Certificate.Obtain(request) if err != nil { @@ -168,7 +173,7 @@ func renewForCSR(ctx *cli.Context, client *lego.Client, certsStorage *Certificat timeLeft := cert.NotAfter.Sub(time.Now().UTC()) log.Infof("[%s] acme: Trying renewal with %d hours remaining", domain, int(timeLeft.Hours())) - certRes, err := client.Certificate.ObtainForCSR(*csr, bundle) + certRes, err := client.Certificate.ObtainForCSR(*csr, bundle, ctx.String("preferred-chain")) if err != nil { log.Fatal(err) } diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index 4a869fd4..eacf2ed0 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -43,6 +43,10 @@ func createRun() cli.Command { Name: "run-hook", Usage: "Define a hook. The hook is executed when the certificates are effectively created.", }, + cli.StringFlag{ + Name: "preferred-chain", + Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.", + }, }, } } @@ -159,9 +163,10 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso if len(domains) > 0 { // obtain a certificate, generating a new private key request := certificate.ObtainRequest{ - Domains: domains, - Bundle: bundle, - MustStaple: ctx.Bool("must-staple"), + Domains: domains, + Bundle: bundle, + MustStaple: ctx.Bool("must-staple"), + PreferredChain: ctx.String("preferred-chain"), } return client.Certificate.Obtain(request) } @@ -173,5 +178,5 @@ func obtainCertificate(ctx *cli.Context, client *lego.Client) (*certificate.Reso } // obtain a certificate for this CSR - return client.Certificate.ObtainForCSR(*csr, bundle) + return client.Certificate.ObtainForCSR(*csr, bundle, ctx.String("preferred-chain")) } diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index e5237342..d5fae6a6 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -307,7 +307,7 @@ func TestChallengeTLS_Client_ObtainForCSR(t *testing.T) { csr, err := x509.ParseCertificateRequest(csrRaw) require.NoError(t, err) - resource, err := client.Certificate.ObtainForCSR(*csr, true) + resource, err := client.Certificate.ObtainForCSR(*csr, true, "") require.NoError(t, err) require.NotNil(t, resource)