TLS-ALPN-01 Challenge (#572)

* feat: implemented TLS-ALPN-01 challenge
This commit is contained in:
Wyatt Johnson 2018-06-13 23:20:56 +00:00 committed by Ludovic Fernandez
parent c4bbb4b819
commit d457f70ae0
11 changed files with 326 additions and 15 deletions

View file

@ -56,6 +56,7 @@ Otherwise the release will be tagged with the `dev` version identifier.
- Robust implementation of all ACME challenges - Robust implementation of all ACME challenges
- HTTP (http-01) - HTTP (http-01)
- DNS (dns-01) - DNS (dns-01)
- TLS (tls-alpn-01)
- SAN certificate support - SAN certificate support
- Comes with multiple optional [DNS providers](https://github.com/xenolf/lego/tree/master/providers/dns) - 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) - [Custom challenge solvers](https://github.com/xenolf/lego/wiki/Writing-a-Challenge-Solver)
@ -75,9 +76,6 @@ NAME:
USAGE: USAGE:
lego [global options] command [command options] [arguments...] lego [global options] command [command options] [arguments...]
VERSION:
0.4.1
COMMANDS: COMMANDS:
run Register an account, then create and install a certificate run Register an account, then create and install a certificate
revoke Revoke 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") --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. --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. --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") --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") --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",. --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 --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. --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 --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. --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) --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) --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: 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. 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.

View file

@ -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 // 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 // Note: DNS01Record returns a DNS record which will fulfill this challenge
DNS01 = Challenge("dns-01") 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")
) )

View file

@ -81,8 +81,10 @@ func NewClient(caDirURL string, user User, keyType KeyType) (*Client, error) {
// REVIEW: best possibility? // REVIEW: best possibility?
// Add all available solvers with the right index as per ACME // Add all available solvers with the right index as per ACME
// spec to this map. Otherwise they won`t be found. // spec to this map. Otherwise they won`t be found.
solvers := make(map[Challenge]solver) solvers := map[Challenge]solver{
solvers[HTTP01] = &httpChallenge{jws: jws, validate: validate, provider: &HTTPProviderServer{}} 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 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} c.solvers[challenge] = &httpChallenge{jws: c.jws, validate: validate, provider: p}
case DNS01: case DNS01:
c.solvers[challenge] = &dnsChallenge{jws: c.jws, validate: validate, provider: p} 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: default:
return fmt.Errorf("Unknown challenge %v", challenge) return fmt.Errorf("unknown challenge %v", challenge)
} }
return nil return nil
} }
@ -119,6 +123,24 @@ func (c *Client) SetHTTPAddress(iface string) error {
return nil 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. // ExcludeChallenges explicitly removes challenges from the pool for solving.
func (c *Client) ExcludeChallenges(challenges []Challenge) { func (c *Client) ExcludeChallenges(challenges []Challenge) {
// Loop through all challenges and delete the requested one if found. // Loop through all challenges and delete the requested one if found.

View file

@ -53,7 +53,7 @@ func TestNewClient(t *testing.T) {
t.Errorf("Expected keyType to be %s but was %s", keyType, client.keyType) 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) t.Fatalf("Expected %d solver(s), got %d", expected, actual)
} }
} }

View file

@ -303,8 +303,8 @@ func getCertExpiration(cert []byte) (time.Time, error) {
return pCert.NotAfter, nil return pCert.NotAfter, nil
} }
func generatePemCert(privKey *rsa.PrivateKey, domain string) ([]byte, error) { func generatePemCert(privKey *rsa.PrivateKey, domain string, extensions []pkix.Extension) ([]byte, error) {
derBytes, err := generateDerCert(privKey, time.Time{}, domain) derBytes, err := generateDerCert(privKey, time.Time{}, domain, extensions)
if err != nil { if err != nil {
return nil, err 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 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) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil { if err != nil {
@ -334,6 +334,7 @@ func generateDerCert(privKey *rsa.PrivateKey, expiration time.Time, domain strin
KeyUsage: x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true, BasicConstraintsValid: true,
DNSNames: []string{domain}, DNSNames: []string{domain},
ExtraExtensions: extensions,
} }
return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) return x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)

View file

@ -60,7 +60,7 @@ func TestPEMCertExpiration(t *testing.T) {
expiration := time.Now().Add(365) expiration := time.Now().Add(365)
expiration = expiration.Round(time.Second) 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 { if err != nil {
t.Fatal("Error generating cert:", err) t.Fatal("Error generating cert:", err)
} }

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

6
cli.go
View file

@ -137,7 +137,7 @@ func main() {
}, },
cli.StringSliceFlag{ cli.StringSliceFlag{
Name: "exclude, x", 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{ cli.StringFlag{
Name: "webroot", Name: "webroot",
@ -151,6 +151,10 @@ func main() {
Name: "http", Name: "http",
Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", 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{ cli.StringFlag{
Name: "dns", Name: "dns",
Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.", Usage: "Solve a DNS challenge using the specified provider. Disables all other challenges. Run 'lego dnshelp' for help on usage.",

View file

@ -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") { if c.GlobalIsSet("dns") {
provider, err := dns.NewDNSChallengeProviderByName(c.GlobalString("dns")) provider, err := dns.NewDNSChallengeProviderByName(c.GlobalString("dns"))
if err != nil { if err != nil {