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 diff --git a/acme/client.go b/acme/client.go index 5dec496c..d4febfb2 100644 --- a/acme/client.go +++ b/acme/client.go @@ -278,6 +278,65 @@ 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 x509.CertificateRequest, bundle bool) (CertificateResource, map[string]error) { + // figure out what domains it concerns + // start with the common name + domains := []string{csr.Subject.CommonName} + + // loop over the SubjectAltName DNS names +DNSNames: + for _, sanName := range csr.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.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 +} + // 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 @@ -404,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) @@ -528,7 +599,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 +607,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 +620,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 +642,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"), 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.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..99da0144 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -2,7 +2,9 @@ package main import ( "bufio" + "crypto/x509" "encoding/json" + "encoding/pem" "io/ioutil" "os" "path" @@ -148,9 +150,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 +210,37 @@ func handleTOS(c *cli.Context, client *acme.Client, acc *Account) { } } +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 + 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" { + 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 x509.ParseCertificateRequest(raw) +} + func run(c *cli.Context) error { conf, acc, client := setup(c) if acc.Registration == nil { @@ -232,11 +268,34 @@ 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)") + } + + 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"), !c.Bool("no-bundle"), 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, !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())