Repository: xenolf/lego
Pull request #572 (2018-06-09 05:03:04)

TLS-ALPN-01 Challenge
# What does this do?

Adds support for the draft [TLS-ALPN-01](https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01) challenge.

# How do I test?

1. [Download, install, and run the latest pebble.](https://github.com/letsencrypt/pebble#install)
2. Until #571 is merged, you have to manually patch lego to allow it to interact with pebble's self signed certificates:

```diff
diff --git a/acme/http.go b/acme/http.go
index f45e7f8..ab10f88 100644
--- a/acme/http.go
+++ b/acme/http.go
@@ -1,6 +1,7 @@
 package acme
 
 import (
+	"crypto/tls"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -26,6 +27,9 @@ var HTTPClient = http.Client{
 		TLSHandshakeTimeout:   15 * time.Second,
 		ResponseHeaderTimeout: 15 * time.Second,
 		ExpectContinueTimeout: 1 * time.Second,
+		TLSClientConfig: &tls.Config{
+			InsecureSkipVerify: true,
+		},
 	},
 }
```
3. Add `127.0.0.1 example.com` to your `/etc/hosts` file.
3. Build, test, and run lego with the following:

```bash
# test and build lego
make test build

# attempt to get a certificate via TLS-ALPN-01
./lego -s https://localhost:14000/dir --email=me@example.com --exclude http-01 --exclude dns-01 -d example.com --tls :5001 run
```

**Note**: You may have issues on a Mac if you have the firewall turned on. For the duration of the test, turn these off to ensure that pebble/lego can communicate. (just be sure to turn it back on!)

mholt: Awesome -- I gave my approval, but this shouldn't be merged into master until Let's Encrypt supports tls-alpn in production.

An alternative is that we could take this challenge out of the default rotation so that it has to be explicitly enabled, until Let's Encrypt has deployed it to production. That way we can merge anytime.
mholt: On second thought, the ACME protocol should only choose a challenge type that is mutually supported, so we should be able to merge this in at any time without adverse effects.

Patch:
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/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/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/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 {


Test patch:
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_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_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)
+	}
+}