From 8d7afd02b9107583a454295f124cfd3c999bc1f9 Mon Sep 17 00:00:00 2001 From: Will Glynn Date: Thu, 11 Feb 2016 19:00:41 -0600 Subject: [PATCH 1/4] Add ObtainCertificateForCSR() This commit also breaks requestCertificate() into two parts, the first of which generates a CSR, the second of which became requestCertificateForCsr() which does what the name implies. --- acme/client.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/acme/client.go b/acme/client.go index 5dec496c..d4e4f702 100644 --- a/acme/client.go +++ b/acme/client.go @@ -278,6 +278,69 @@ func (c *Client) AgreeToTOS() error { return err } +// ObtainCertificateForCSR tries to obtain a certificate matching the CSR passed into it. +// The domains are inferred from the CommonName and SubjectAltNames, if any. The private key +// for this CSR is not required. +// If bundle is true, the []byte contains both the issuer certificate and +// your issued certificate as a bundle. +// This function will never return a partial certificate. If one domain in the list fails, +// the whole certificate will fail. +func (c *Client) ObtainCertificateForCSR(csr []byte, bundle bool) (CertificateResource, map[string]error) { + // parse the CSR + parsedCsr, err := x509.ParseCertificateRequest(csr) + if err != nil { + return CertificateResource{}, map[string]error{"csr": err} + } + + // figure out what domains it concerns + // start with the common name + domains := []string{parsedCsr.Subject.CommonName} + + // loop over the SubjectAltName DNS names +DNSNames: + for _, sanName := range parsedCsr.DNSNames { + // / + for _, existingName := range domains { + if existingName == sanName { + // duplicate; skip this name + continue DNSNames + } + } + + // name is unique + domains = append(domains, sanName) + } + + if bundle { + logf("[INFO][%s] acme: Obtaining bundled SAN certificate given a CSR", strings.Join(domains, ", ")) + } else { + logf("[INFO][%s] acme: Obtaining SAN certificate given a CSR", strings.Join(domains, ", ")) + } + + challenges, failures := c.getChallenges(domains) + // If any challenge fails - return. Do not generate partial SAN certificates. + if len(failures) > 0 { + return CertificateResource{}, failures + } + + errs := c.solveChallenges(challenges) + // If any challenge fails - return. Do not generate partial SAN certificates. + if len(errs) > 0 { + return CertificateResource{}, errs + } + + logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) + + cert, err := c.requestCertificateForCsr(challenges, bundle, csr, nil) + if err != nil { + for _, chln := range challenges { + failures[chln.Domain] = err + } + } + + return cert, failures +} + // ObtainCertificate tries to obtain a single certificate using all domains passed into it. // The first domain in domains is used for the CommonName field of the certificate, all other // domains are added using the Subject Alternate Names extension. A new private key is generated @@ -528,7 +591,6 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, return CertificateResource{}, errors.New("Passed no authorizations to requestCertificate!") } - commonName := authz[0] var err error if privKey == nil { privKey, err = generatePrivateKey(c.keyType) @@ -537,11 +599,11 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, } } + // determine certificate name(s) based on the authorization resources + commonName := authz[0] var san []string - var authURLs []string for _, auth := range authz[1:] { san = append(san, auth.Domain) - authURLs = append(authURLs, auth.AuthURL) } // TODO: should the CSR be customizable? @@ -550,6 +612,17 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, return CertificateResource{}, err } + return c.requestCertificateForCsr(authz, bundle, csr, pemEncode(privKey)) +} + +func (c *Client) requestCertificateForCsr(authz []authorizationResource, bundle bool, csr []byte, privateKeyPem []byte) (CertificateResource, error) { + commonName := authz[0] + + var authURLs []string + for _, auth := range authz[1:] { + authURLs = append(authURLs, auth.AuthURL) + } + csrString := base64.URLEncoding.EncodeToString(csr) jsonBytes, err := json.Marshal(csrMessage{Resource: "new-cert", Csr: csrString, Authorizations: authURLs}) if err != nil { @@ -561,7 +634,6 @@ func (c *Client) requestCertificate(authz []authorizationResource, bundle bool, return CertificateResource{}, err } - privateKeyPem := pemEncode(privKey) cerRes := CertificateResource{ Domain: commonName.Domain, CertURL: resp.Header.Get("Location"), From 333af549067553a95e97859d1ec4795741dc9a45 Mon Sep 17 00:00:00 2001 From: Will Glynn Date: Thu, 11 Feb 2016 19:02:00 -0600 Subject: [PATCH 2/4] Add --csr option to generate a certificate for an existing CSR --- cli.go | 4 +++ cli_handlers.go | 70 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cli.go b/cli.go index 947ed345..9747ab98 100644 --- a/cli.go +++ b/cli.go @@ -103,6 +103,10 @@ func main() { Name: "domains, d", Usage: "Add domains to the process", }, + cli.StringFlag{ + Name: "csr, c", + Usage: "Certificate signing request filename, if an external CSR is to be used", + }, cli.StringFlag{ Name: "server, s", Value: "https://acme-v01.api.letsencrypt.org/directory", diff --git a/cli_handlers.go b/cli_handlers.go index 06d534c4..8e06947c 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -3,6 +3,7 @@ package main import ( "bufio" "encoding/json" + "encoding/pem" "io/ioutil" "os" "path" @@ -148,9 +149,12 @@ func saveCertRes(certRes acme.CertificateResource, conf *Configuration) { logger().Fatalf("Unable to save Certificate for domain %s\n\t%s", certRes.Domain, err.Error()) } - err = ioutil.WriteFile(privOut, certRes.PrivateKey, 0600) - if err != nil { - logger().Fatalf("Unable to save PrivateKey for domain %s\n\t%s", certRes.Domain, err.Error()) + if certRes.PrivateKey != nil { + // if we were given a CSR, we don't know the private key + err = ioutil.WriteFile(privOut, certRes.PrivateKey, 0600) + if err != nil { + logger().Fatalf("Unable to save PrivateKey for domain %s\n\t%s", certRes.Domain, err.Error()) + } } jsonBytes, err := json.MarshalIndent(certRes, "", "\t") @@ -205,6 +209,36 @@ func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { } } +func readCSRFile(filename string) ([]byte, error) { + bytes, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + // see if we can find a PEM-encoded CSR + var p *pem.Block + rest := bytes + for { + // decode a PEM block + p, rest = pem.Decode(rest) + + // did we fail? + if p == nil { + break + } + + // did we get a CSR? + if p.Type == "CERTIFICATE REQUEST" { + return p.Bytes, nil + } + } + + // no PEM-encoded CSR + // assume we were given a DER-encoded ASN.1 CSR + // (if this assumption is wrong, parsing these bytes will fail) + return bytes, nil +} + func run(c *cli.Context) error { conf, acc, client := setup(c) if acc.Registration == nil { @@ -232,11 +266,35 @@ func run(c *cli.Context) error { handleTOS(c, client, acc) } - if len(c.GlobalStringSlice("domains")) == 0 { - logger().Fatal("Please specify --domains or -d") + // we require either domains or csr, but not both + hasDomains := len(c.GlobalStringSlice("domains")) > 0 + hasCsr := len(c.GlobalString("csr")) > 0 + if hasDomains && hasCsr { + logger().Fatal("Please specify either --domains/-d or --csr/-c, but not both") + } + if !hasDomains && !hasCsr { + logger().Fatal("Please specify --domains/-d (or --csr/-c if you already have a CSR)") } - cert, failures := client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil) + var cert acme.CertificateResource + var failures map[string]error + + if hasDomains { + // obtain a certificate, generating a new private key + cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), true, nil) + } else { + // read the CSR + csr, err := readCSRFile(c.GlobalString("csr")) + if err != nil { + // we couldn't read the CSR + failures = map[string]error{"csr": err} + } else { + // obtain a certificate for this CSR + cert, failures = client.ObtainCertificateForCSR(csr, true) + } + } + + cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil) if len(failures) > 0 { for k, v := range failures { logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error()) From 01e2a30802219e5e7be1acfed21aa0866099ad88 Mon Sep 17 00:00:00 2001 From: Will Glynn Date: Thu, 11 Feb 2016 19:08:36 -0600 Subject: [PATCH 3/4] Document --csr flag --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff692132..d2ced1bd 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ docker build -t lego . #### Features - Register with CA -- Obtain certificates +- Obtain certificates, both from scratch or with an existing CSR - Renew certificates - Revoke certificates - Robust implementation of all ACME challenges @@ -89,6 +89,7 @@ COMMANDS: GLOBAL OPTIONS: --domains, -d [--domains option --domains option] Add domains to the process + --csr, -c Certificate signing request filename, if an external CSR is to be used --server, -s "https://acme-v01.api.letsencrypt.org/directory" CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. --email, -m Email used for registration and recovery contact. --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. @@ -130,6 +131,14 @@ $ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=my_id AWS_SECRET_ACCESS_KEY=my_key lego Note that `--dns=foo` implies `--exclude=http-01` and `--exclude=tls-sni-01`. lego will not attempt other challenges if you've told it to use DNS instead. +Obtain a certificate given a certificate signing request (CSR) generated by something else: + +```bash +$ lego --email="foo@bar.com" --csr=/path/to/csr.pem run +``` + +(lego will infer the domains to be validated based on the contents of the CSR, so make sure the CSR's Common Name and optional SubjectAltNames are set correctly.) + lego defaults to communicating with the production Let's Encrypt ACME server. If you'd like to test something without issuing real certificates, consider using the staging endpoint instead: ```bash From 575370e196df110567eeb17a2c59815022acee28 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Tue, 7 Jun 2016 19:50:52 -0700 Subject: [PATCH 4/4] cert: Extend acme.CertificateResource, support CSRs on renew client.RenewCertificate now supports CSRs, and in fact prefers them, when renewing certificates. In other words, if the certificate was created via a CSR then using that will be attempted before re-generating off a new private key. Also adjusted the API of ObtainCertificateForCSR to be a little more in line with the original ObtainCertificate function. --- acme/client.go | 30 +++++++++++++++++++----------- acme/crypto.go | 16 ++++++++++++++++ acme/messages.go | 1 + cli_handlers.go | 13 +++++++------ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/acme/client.go b/acme/client.go index d4e4f702..d4febfb2 100644 --- a/acme/client.go +++ b/acme/client.go @@ -285,21 +285,14 @@ func (c *Client) AgreeToTOS() error { // your issued certificate as a bundle. // This function will never return a partial certificate. If one domain in the list fails, // the whole certificate will fail. -func (c *Client) ObtainCertificateForCSR(csr []byte, bundle bool) (CertificateResource, map[string]error) { - // parse the CSR - parsedCsr, err := x509.ParseCertificateRequest(csr) - if err != nil { - return CertificateResource{}, map[string]error{"csr": err} - } - +func (c *Client) ObtainCertificateForCSR(csr x509.CertificateRequest, bundle bool) (CertificateResource, map[string]error) { // figure out what domains it concerns // start with the common name - domains := []string{parsedCsr.Subject.CommonName} + domains := []string{csr.Subject.CommonName} // loop over the SubjectAltName DNS names DNSNames: - for _, sanName := range parsedCsr.DNSNames { - // / + for _, sanName := range csr.DNSNames { for _, existingName := range domains { if existingName == sanName { // duplicate; skip this name @@ -331,13 +324,16 @@ DNSNames: logf("[INFO][%s] acme: Validations succeeded; requesting certificates", strings.Join(domains, ", ")) - cert, err := c.requestCertificateForCsr(challenges, bundle, csr, nil) + cert, err := c.requestCertificateForCsr(challenges, bundle, csr.Raw, nil) if err != nil { for _, chln := range challenges { failures[chln.Domain] = err } } + // Add the CSR to the certificate so that it can be used for renewals. + cert.CSR = pemEncode(&csr) + return cert, failures } @@ -467,6 +463,18 @@ func (c *Client) RenewCertificate(cert CertificateResource, bundle bool) (Certif return cert, nil } + // If the certificate is the same, then we need to request a new certificate. + // Start by checking to see if the certificate was based off a CSR, and + // use that if it's defined. + if len(cert.CSR) > 0 { + csr, err := pemDecodeTox509CSR(cert.CSR) + if err != nil { + return CertificateResource{}, err + } + newCert, failures := c.ObtainCertificateForCSR(*csr, bundle) + return newCert, failures[cert.Domain] + } + var privKey crypto.PrivateKey if cert.PrivateKey != nil { privKey, err = parsePEMPrivateKey(cert.PrivateKey) diff --git a/acme/crypto.go b/acme/crypto.go index fc20442f..33240b61 100644 --- a/acme/crypto.go +++ b/acme/crypto.go @@ -236,6 +236,9 @@ func pemEncode(data interface{}) []byte { case *rsa.PrivateKey: pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} break + case *x509.CertificateRequest: + pemBlock = &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: key.Raw} + break case derCertificateBytes: pemBlock = &pem.Block{Type: "CERTIFICATE", Bytes: []byte(data.(derCertificateBytes))} } @@ -261,6 +264,19 @@ func pemDecodeTox509(pem []byte) (*x509.Certificate, error) { return x509.ParseCertificate(pemBlock.Bytes) } +func pemDecodeTox509CSR(pem []byte) (*x509.CertificateRequest, error) { + pemBlock, err := pemDecode(pem) + if pemBlock == nil { + return nil, err + } + + if pemBlock.Type != "CERTIFICATE REQUEST" { + return nil, fmt.Errorf("PEM block is not a certificate request") + } + + return x509.ParseCertificateRequest(pemBlock.Bytes) +} + // GetPEMCertExpiration returns the "NotAfter" date of a PEM encoded certificate. // The certificate has to be PEM encoded. Any other encodings like DER will fail. func GetPEMCertExpiration(cert []byte) (time.Time, error) { diff --git a/acme/messages.go b/acme/messages.go index a6539b96..0efeae67 100644 --- a/acme/messages.go +++ b/acme/messages.go @@ -113,4 +113,5 @@ type CertificateResource struct { AccountRef string `json:"accountRef,omitempty"` PrivateKey []byte `json:"-"` Certificate []byte `json:"-"` + CSR []byte `json:"-"` } diff --git a/cli_handlers.go b/cli_handlers.go index 8e06947c..99da0144 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "crypto/x509" "encoding/json" "encoding/pem" "io/ioutil" @@ -209,11 +210,12 @@ func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { } } -func readCSRFile(filename string) ([]byte, error) { +func readCSRFile(filename string) (*x509.CertificateRequest, error) { bytes, err := ioutil.ReadFile(filename) if err != nil { return nil, err } + raw := bytes // see if we can find a PEM-encoded CSR var p *pem.Block @@ -229,14 +231,14 @@ func readCSRFile(filename string) ([]byte, error) { // did we get a CSR? if p.Type == "CERTIFICATE REQUEST" { - return p.Bytes, nil + raw = p.Bytes } } // no PEM-encoded CSR // assume we were given a DER-encoded ASN.1 CSR // (if this assumption is wrong, parsing these bytes will fail) - return bytes, nil + return x509.ParseCertificateRequest(raw) } func run(c *cli.Context) error { @@ -281,7 +283,7 @@ func run(c *cli.Context) error { if hasDomains { // obtain a certificate, generating a new private key - cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), true, nil) + cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil) } else { // read the CSR csr, err := readCSRFile(c.GlobalString("csr")) @@ -290,11 +292,10 @@ func run(c *cli.Context) error { failures = map[string]error{"csr": err} } else { // obtain a certificate for this CSR - cert, failures = client.ObtainCertificateForCSR(csr, true) + cert, failures = client.ObtainCertificateForCSR(*csr, !c.Bool("no-bundle")) } } - cert, failures = client.ObtainCertificate(c.GlobalStringSlice("domains"), !c.Bool("no-bundle"), nil) if len(failures) > 0 { for k, v := range failures { logger().Printf("[%s] Could not obtain certificates\n\t%s", k, v.Error())