From d457f70ae0a39ea3e62ddad62caa7c29d33a01e2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 13 Jun 2018 23:20:56 +0000 Subject: [PATCH] TLS-ALPN-01 Challenge (#572) * feat: implemented TLS-ALPN-01 challenge --- README.md | 14 +++-- acme/challenges.go | 2 + acme/client.go | 28 ++++++++- acme/client_test.go | 2 +- acme/crypto.go | 7 ++- acme/crypto_test.go | 2 +- acme/tls_alpn_challenge.go | 95 +++++++++++++++++++++++++++++++ acme/tls_alpn_challenge_server.go | 86 ++++++++++++++++++++++++++++ acme/tls_alpn_challenge_test.go | 92 ++++++++++++++++++++++++++++++ cli.go | 6 +- cli_handlers.go | 7 +++ 11 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 acme/tls_alpn_challenge.go create mode 100644 acme/tls_alpn_challenge_server.go create mode 100644 acme/tls_alpn_challenge_test.go diff --git a/README.md b/README.md index abc39a99..3b85cd9d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Otherwise the release will be tagged with the `dev` version identifier. - Robust implementation of all ACME challenges - HTTP (http-01) - DNS (dns-01) + - TLS (tls-alpn-01) - SAN certificate support - Comes with multiple optional [DNS providers](https://github.com/xenolf/lego/tree/master/providers/dns) - [Custom challenge solvers](https://github.com/xenolf/lego/wiki/Writing-a-Challenge-Solver) @@ -75,9 +76,6 @@ NAME: USAGE: lego [global options] command [command options] [arguments...] -VERSION: - 0.4.1 - COMMANDS: run Register an account, then create and install a certificate revoke Revoke a certificate @@ -91,12 +89,16 @@ GLOBAL OPTIONS: --server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") --email value, -m value 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. + --eab Use External Account Binding for account registration. Requires --kid and --hmac. + --kid value Key identifier from External CA. Used for External Account Binding. + --hmac value MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding. --key-type value, -k value Key type to use for private keys. Supported: rsa2048, rsa4096, rsa8192, ec256, ec384 (default: "rsa2048") - --path value Directory to use for storing the data (default: "/.lego") - --exclude value, -x value Explicitly disallow solvers by name from being used. Solvers: "http-01", "dns-01",. + --path value Directory to use for storing the data (default: "./.lego") + --exclude value, -x value Explicitly disallow solvers by name from being used. Solvers: "http-01", "dns-01", "tls-alpn-01". --webroot value Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge --memcached-host value Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts. --http value Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port + --tls value Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port --dns value Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage. --http-timeout value Set the HTTP timeout value to a specific value in seconds. The default is 10 seconds. (default: 0) --dns-timeout value Set the DNS timeout value to a specific value in seconds. The default is 10 seconds. (default: 0) @@ -130,7 +132,7 @@ HTTP Port: TLS Port: -- All TLS handshakes on port 443 for the TLS-SNI challenge. +- All TLS handshakes on port 443 for the TLS-ALPN challenge. This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding. diff --git a/acme/challenges.go b/acme/challenges.go index cf7bd7f7..1140b107 100644 --- a/acme/challenges.go +++ b/acme/challenges.go @@ -10,4 +10,6 @@ const ( // DNS01 is the "dns-01" ACME challenge https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#dns // Note: DNS01Record returns a DNS record which will fulfill this challenge DNS01 = Challenge("dns-01") + // TLSALPN01 is the "tls-alpn-01" ACME challenge https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 + TLSALPN01 = Challenge("tls-alpn-01") ) diff --git a/acme/client.go b/acme/client.go index a9e34608..6e4c9d5f 100644 --- a/acme/client.go +++ b/acme/client.go @@ -81,8 +81,10 @@ func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) { // REVIEW: best possibility? // Add all available solvers with the right index as per ACME // spec to this map. Otherwise they won`t be found. - solvers := make(map[Challenge]solver) - solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}} + solvers := map[Challenge]solver{ + HTTP01: &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}}, + TLSALPN01: &tlsALPNChallenge{jws: jws, validate: validate, provider: &TLSALPNProviderServer{}}, + } return &Client{directory: dir, user: user, jws: jws, keyType: keyType, solvers: solvers}, nil } @@ -94,8 +96,10 @@ func (c *Client) SetChallengeProvider(challenge Challenge, p ChallengeProvider) c.solvers[challenge] = &httpChallenge{jws: c.jws, validate: validate, provider: p} case DNS01: c.solvers[challenge] = &dnsChallenge{jws: c.jws, validate: validate, provider: p} + case TLSALPN01: + c.solvers[challenge] = &tlsALPNChallenge{jws: c.jws, validate: validate, provider: p} default: - return fmt.Errorf("Unknown challenge %v", challenge) + return fmt.Errorf("unknown challenge %v", challenge) } return nil } @@ -119,6 +123,24 @@ func (c *Client) SetHTTPAddress(iface string) error { return nil } +// SetTLSAddress specifies a custom interface:port to be used for TLS based challenges. +// If this option is not used, the default port 443 and all interfaces will be used. +// To only specify a port and no interface use the ":port" notation. +// +// NOTE: This REPLACES any custom TLS-ALPN provider previously set by calling +// c.SetChallengeProvider with the default TLS-ALPN challenge provider. +func (c *Client) SetTLSAddress(iface string) error { + host, port, err := net.SplitHostPort(iface) + if err != nil { + return err + } + + if chlng, ok := c.solvers[TLSALPN01]; ok { + chlng.(*tlsALPNChallenge).provider = NewTLSALPNProviderServer(host, port) + } + return nil +} + // ExcludeChallenges explicitly removes challenges from the pool for solving. func (c *Client) ExcludeChallenges(challenges []Challenge) { // Loop through all challenges and delete the requested one if found. diff --git a/acme/client_test.go b/acme/client_test.go index 1e51b9a6..b37d1bdf 100644 --- a/acme/client_test.go +++ b/acme/client_test.go @@ -53,7 +53,7 @@ func TestNewClient(t *testing.T) { t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType) } - if expected, actual := 1, len(client.solvers); actual != expected { + if expected, actual := 2, len(client.solvers); actual != expected { t.Fatalf("Expected %d solver(s), got %d", expected, actual) } } diff --git a/acme/crypto.go b/acme/crypto.go index 7d4f4425..35712ab9 100644 --- a/acme/crypto.go +++ b/acme/crypto.go @@ -303,8 +303,8 @@ func getCertExpiration(cert []byte) (time.Time, error) { return pCert.NotAfter, nil } -func generatePemCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) { - derBytes, err := generateDerCert(privKey, time.Time{}, domain) +func generatePemCert(privKey *rsa.PrivateKey, domain string, extensions []pkix.Extension) ([]byte, error) { + derBytes, err := generateDerCert(privKey, time.Time{}, domain, extensions) if err != nil { return nil, err } @@ -312,7 +312,7 @@ func generatePemCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) { return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), nil } -func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain string) ([]byte, error) { +func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain string, extensions []pkix.Extension) ([]byte, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { @@ -334,6 +334,7 @@ func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain strin KeyUsage: x509.KeyUsageKeyEncipherment, BasicConstraintsValid: true, DNSNames: []string{domain}, + ExtraExtensions: extensions, } return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) diff --git a/acme/crypto_test.go b/acme/crypto_test.go index 3ddf5d01..f32611e1 100644 --- a/acme/crypto_test.go +++ b/acme/crypto_test.go @@ -60,7 +60,7 @@ func TestPEMCertExpiration(t *testing.T) { expiration := time.Now().Add(365) expiration = expiration.Round(time.Second) - certBytes, err := generateDerCert(privKey.(*rsa.PrivateKey), expiration, "test.com") + certBytes, err := generateDerCert(privKey.(*rsa.PrivateKey), expiration, "test.com", nil) if err != nil { t.Fatal("Error generating cert:", err) } diff --git a/acme/tls_alpn_challenge.go b/acme/tls_alpn_challenge.go new file mode 100644 index 00000000..6b346cd0 --- /dev/null +++ b/acme/tls_alpn_challenge.go @@ -0,0 +1,95 @@ +package acme + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + + "github.com/xenolf/lego/log" +) + +// idPeAcmeIdentifierV1 is the SMI Security for PKIX Certification Extension +// OID referencing the ACME extension. Reference: +// https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-5.1 +var idPeAcmeIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 30, 1} + +type tlsALPNChallenge struct { + jws *jws + validate validateFunc + provider ChallengeProvider +} + +// Solve manages the provider to validate and solve the challenge. +func (t *tlsALPNChallenge) Solve(chlng challenge, domain string) error { + log.Printf("[INFO][%s] acme: Trying to solve TLS-ALPN-01", domain) + + // Generate the Key Authorization for the challenge + keyAuth, err := getKeyAuthorization(chlng.Token, t.jws.privKey) + if err != nil { + return err + } + + err = t.provider.Present(domain, chlng.Token, keyAuth) + if err != nil { + return fmt.Errorf("[%s] error presenting token: %v", domain, err) + } + defer func() { + err := t.provider.CleanUp(domain, chlng.Token, keyAuth) + if err != nil { + log.Printf("[%s] error cleaning up: %v", domain, err) + } + }() + + return t.validate(t.jws, domain, chlng.URL, challenge{Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth}) +} + +// TLSALPNChallengeCert returns a certificate with the acmeValidation-v1 +// extension and domain name for the `tls-alpn-01` challenge. +func TLSALPNChallengeCert(domain, keyAuth string) (*tls.Certificate, error) { + // Generate a new RSA key for the certificates. + tempPrivKey, err := generatePrivateKey(RSA2048) + if err != nil { + return nil, err + } + + // Encode the private key into a PEM format. We'll need to use it to + // generate the x509 keypair. + rsaPrivKey := tempPrivKey.(*rsa.PrivateKey) + rsaPrivPEM := pemEncode(rsaPrivKey) + + // Compute the SHA-256 digest of the key authorization. + zBytes := sha256.Sum256([]byte(keyAuth)) + + value, err := asn1.Marshal(zBytes[:sha256.Size]) + if err != nil { + return nil, err + } + + // Add the keyAuth digest as the acmeValidation-v1 extension (marked as + // critical such that it won't be used by non-ACME software). Reference: + // https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-3 + extensions := []pkix.Extension{ + { + Id: idPeAcmeIdentifierV1, + Critical: true, + Value: value, + }, + } + + // Generate the PEM certificate using the provided private key, domain, and + // extra extensions. + tempCertPEM, err := generatePemCert(rsaPrivKey, domain, extensions) + if err != nil { + return nil, err + } + + certificate, err := tls.X509KeyPair(tempCertPEM, rsaPrivPEM) + if err != nil { + return nil, err + } + + return &certificate, nil +} diff --git a/acme/tls_alpn_challenge_server.go b/acme/tls_alpn_challenge_server.go new file mode 100644 index 00000000..73f605a5 --- /dev/null +++ b/acme/tls_alpn_challenge_server.go @@ -0,0 +1,86 @@ +package acme + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" +) + +const ( + // acmeTLS1Protocol is the ALPN Protocol ID for the ACME-TLS/1 Protocol. + acmeTLS1Protocol = "acme-tls/1" + + // defaultTLSPort is the port that the TLSALPNProviderServer will default to + // when no other port is provided. + defaultTLSPort = "443" +) + +// TLSALPNProviderServer implements ChallengeProvider for `TLS-ALPN-01` +// challenge. It may be instantiated without using the NewTLSALPNProviderServer +// if you want only to use the default values. +type TLSALPNProviderServer struct { + iface string + port string + listener net.Listener +} + +// NewTLSALPNProviderServer creates a new TLSALPNProviderServer on the selected +// interface and port. Setting iface and / or port to an empty string will make +// the server fall back to the "any" interface and port 443 respectively. +func NewTLSALPNProviderServer(iface, port string) *TLSALPNProviderServer { + return &TLSALPNProviderServer{iface: iface, port: port} +} + +// Present generates a certificate with a SHA-256 digest of the keyAuth provided +// as the acmeValidation-v1 extension value to conform to the ACME-TLS-ALPN +// spec. +func (t *TLSALPNProviderServer) Present(domain, token, keyAuth string) error { + if t.port == "" { + // Fallback to port 443 if the port was not provided. + t.port = defaultTLSPort + } + + // Generate the challenge certificate using the provided keyAuth and domain. + cert, err := TLSALPNChallengeCert(domain, keyAuth) + if err != nil { + return err + } + + // Place the generated certificate with the extension into the TLS config + // so that it can serve the correct details. + tlsConf := new(tls.Config) + tlsConf.Certificates = []tls.Certificate{*cert} + + // We must set that the `acme-tls/1` application level protocol is supported + // so that the protocol negotiation can succeed. Reference: + // https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01#section-5.2 + tlsConf.NextProtos = []string{acmeTLS1Protocol} + + // Create the listener with the created tls.Config. + t.listener, err = tls.Listen("tcp", net.JoinHostPort(t.iface, t.port), tlsConf) + if err != nil { + return fmt.Errorf("could not start HTTPS server for challenge -> %v", err) + } + + // Shut the server down when we're finished. + go func() { + http.Serve(t.listener, nil) + }() + + return nil +} + +// CleanUp closes the HTTPS server. +func (t *TLSALPNProviderServer) CleanUp(domain, token, keyAuth string) error { + if t.listener == nil { + return nil + } + + // Server was created, close it. + if err := t.listener.Close(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil +} diff --git a/acme/tls_alpn_challenge_test.go b/acme/tls_alpn_challenge_test.go new file mode 100644 index 00000000..d53834f2 --- /dev/null +++ b/acme/tls_alpn_challenge_test.go @@ -0,0 +1,92 @@ +package acme + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/asn1" + "strings" + "testing" +) + +func TestTLSALPNChallenge(t *testing.T) { + domain := "localhost:23457" + privKey, _ := rsa.GenerateKey(rand.Reader, 512) + j := &jws{privKey: privKey} + clientChallenge := challenge{Type: string(TLSALPN01), Token: "tlsalpn1"} + mockValidate := func(_ *jws, _, _ string, chlng challenge) error { + conn, err := tls.Dial("tcp", domain, &tls.Config{ + InsecureSkipVerify: true, + }) + if err != nil { + t.Errorf("Expected to connect to challenge server without an error. %v", err) + } + + // Expect the server to only return one certificate + connState := conn.ConnectionState() + if count := len(connState.PeerCertificates); count != 1 { + t.Errorf("Expected the challenge server to return exactly one certificate but got %d", count) + } + + remoteCert := connState.PeerCertificates[0] + if count := len(remoteCert.DNSNames); count != 1 { + t.Errorf("Expected the challenge certificate to have exactly one DNSNames entry but had %d", count) + } + + if remoteCert.DNSNames[0] != domain { + t.Errorf("Expected the challenge certificate DNSName to match %s but was %s", domain, remoteCert.DNSNames[0]) + } + + if len(remoteCert.Extensions) == 0 { + t.Error("Expected the challenge certificate to contain extensions, it contained nothing") + } + + idx := -1 + for i, ext := range remoteCert.Extensions { + if idPeAcmeIdentifierV1.Equal(ext.Id) { + idx = i + break + } + } + + if idx == -1 { + t.Fatal("Expected the challenge certificate to contain an extension with the id-pe-acmeIdentifier id, it did not") + } + + ext := remoteCert.Extensions[idx] + + if !ext.Critical { + t.Error("Expected the challenge certificate id-pe-acmeIdentifier extension to be marked as critical, it was not") + } + + zBytes := sha256.Sum256([]byte(chlng.KeyAuthorization)) + value, err := asn1.Marshal(zBytes[:sha256.Size]) + if err != nil { + t.Fatalf("Expected marshaling of the keyAuth to return no error, but was %v", err) + } + if subtle.ConstantTimeCompare(value[:], ext.Value) != 1 { + t.Errorf("Expected the challenge certificate id-pe-acmeIdentifier extension to contain the SHA-256 digest of the keyAuth, %v, but was %v", zBytes[:], ext.Value) + } + + return nil + } + solver := &tlsALPNChallenge{jws: j, validate: mockValidate, provider: &TLSALPNProviderServer{port: "23457"}} + if err := solver.Solve(clientChallenge, domain); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } +} + +func TestTLSALPNChallengeInvalidPort(t *testing.T) { + privKey, _ := rsa.GenerateKey(rand.Reader, 128) + j := &jws{privKey: privKey} + clientChallenge := challenge{Type: string(TLSALPN01), Token: "tlsalpn1"} + solver := &tlsALPNChallenge{jws: j, validate: stubValidate, provider: &TLSALPNProviderServer{port: "123456"}} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Errorf("Solve error: got %v, want error", err) + } else if want, want18 := "invalid port 123456", "123456: invalid port"; !strings.HasSuffix(err.Error(), want) && !strings.HasSuffix(err.Error(), want18) { + t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) + } +} diff --git a/cli.go b/cli.go index 272b9eb5..68e0506e 100644 --- a/cli.go +++ b/cli.go @@ -137,7 +137,7 @@ func main() { }, cli.StringSliceFlag{ Name: "exclude, x", - Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"dns-01\".", + Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"dns-01\", \"tls-alpn-01\".", }, cli.StringFlag{ Name: "webroot", @@ -151,6 +151,10 @@ func main() { Name: "http", Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", }, + cli.StringFlag{ + Name: "tls", + Usage: "Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port", + }, cli.StringFlag{ Name: "dns", Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.", diff --git a/cli_handlers.go b/cli_handlers.go index 88d27966..dd986de5 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -120,6 +120,13 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { } } + if c.GlobalIsSet("tls") { + if !strings.Contains(c.GlobalString("tls"), ":") { + log.Fatalf("The --tls switch only accepts interface:port or :port for its argument.") + } + client.SetTLSAddress(c.GlobalString("tls")) + } + if c.GlobalIsSet("dns") { provider, err := dns.NewDNSChallengeProviderByName(c.GlobalString("dns")) if err != nil {