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
- 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.

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
// 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")
)

View file

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

View file

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

View file

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

View file

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

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{
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.",

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