Skip to content

Commit

Permalink
acme/autocert: add support for tls-alpn-01
Browse files Browse the repository at this point in the history
Because tls.Config now requires more fields to be set
in order for tls-alpn to work, Manager provides a new
TLSConfig method for easier setup.

This CL also adds a new internal package for end-to-end tests.
The package implements a simple ACME CA server.

Fixes golang/go#25013
Fixes golang/go#25901
Updates golang/go#17251

Change-Id: I2687ea8d5c445ddafad5ea2cdd36cd4e7d10bc86
Reviewed-on: https://go-review.googlesource.com/125495
Reviewed-by: Brad Fitzpatrick <[email protected]>
Run-TryBot: Brad Fitzpatrick <[email protected]>
TryBot-Result: Gobot Gobot <[email protected]>
  • Loading branch information
Alex Vaghin committed Jul 23, 2018
1 parent 5e6814e commit 2d1b218
Show file tree
Hide file tree
Showing 6 changed files with 538 additions and 24 deletions.
13 changes: 11 additions & 2 deletions acme/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,17 @@ import (
"time"
)

// LetsEncryptURL is the Directory endpoint of Let's Encrypt CA.
const LetsEncryptURL = "https://acme-v01.api.letsencrypt.org/directory"
const (
// LetsEncryptURL is the Directory endpoint of Let's Encrypt CA.
LetsEncryptURL = "https://acme-v01.api.letsencrypt.org/directory"

// ALPNProto is the ALPN protocol name used by a CA server when validating
// tls-alpn-01 challenges.
//
// Package users must ensure their servers can negotiate the ACME ALPN
// in order for tls-alpn-01 challenge verifications to succeed.
ALPNProto = "acme-tls/1"
)

// idPeACMEIdentifierV1 is the OID for the ACME extension for the TLS-ALPN challenge.
var idPeACMEIdentifierV1 = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 30, 1}
Expand Down
59 changes: 47 additions & 12 deletions acme/autocert/autocert.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ func defaultHostPolicy(context.Context, string) error {
}

// Manager is a stateful certificate manager built on top of acme.Client.
// It obtains and refreshes certificates automatically using "tls-sni-01",
// "tls-sni-02" and "http-01" challenge types, as well as providing them
// to a TLS server via tls.Config.
// It obtains and refreshes certificates automatically using "tls-alpn-01",
// "tls-sni-01", "tls-sni-02" and "http-01" challenge types,
// as well as providing them to a TLS server via tls.Config.
//
// You must specify a cache implementation, such as DirCache,
// to reuse obtained certificates across program restarts.
Expand Down Expand Up @@ -177,9 +177,10 @@ type Manager struct {
// to be provisioned.
// The entries are stored for the duration of the authorization flow.
httpTokens map[string][]byte
// certTokens contains temporary certificates for tls-sni challenges
// certTokens contains temporary certificates for tls-sni and tls-alpn challenges
// and is keyed by token domain name, which matches server name of ClientHello.
// Keys always have ".acme.invalid" suffix.
// Keys always have ".acme.invalid" suffix for tls-sni. Otherwise, they are domain names
// for tls-alpn.
// The entries are stored for the duration of the authorization flow.
certTokens map[string]*tls.Certificate
}
Expand All @@ -188,7 +189,7 @@ type Manager struct {
type certKey struct {
domain string // without trailing dot
isRSA bool // RSA cert for legacy clients (as opposed to default ECDSA)
isToken bool // tls-sni challenge token cert; key type is undefined regardless of isRSA
isToken bool // tls-based challenge token cert; key type is undefined regardless of isRSA
}

func (c certKey) String() string {
Expand All @@ -201,9 +202,22 @@ func (c certKey) String() string {
return c.domain
}

// TLSConfig creates a new TLS config suitable for net/http.Server servers,
// supporting HTTP/2 and the tls-alpn-01 ACME challenge type.
func (m *Manager) TLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: m.GetCertificate,
NextProtos: []string{
"h2", "http/1.1", // enable HTTP/2
acme.ALPNProto, // enable tls-alpn ACME challenges
},
}
}

// GetCertificate implements the tls.Config.GetCertificate hook.
// It provides a TLS certificate for hello.ServerName host, including answering
// *.acme.invalid (TLS-SNI) challenges. All other fields of hello are ignored.
// tls-alpn-01 and *.acme.invalid (tls-sni-01 and tls-sni-02) challenges.
// All other fields of hello are ignored.
//
// If m.HostPolicy is non-nil, GetCertificate calls the policy before requesting
// a new cert. A non-nil error returned from m.HostPolicy halts TLS negotiation.
Expand All @@ -230,10 +244,13 @@ func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

// check whether this is a token cert requested for TLS-SNI challenge
if strings.HasSuffix(name, ".acme.invalid") {
// Check whether this is a token cert requested for TLS-SNI or TLS-ALPN challenge.
if wantsTokenCert(hello) {
m.tokensMu.RLock()
defer m.tokensMu.RUnlock()
// It's ok to use the same token cert key for both tls-sni and tls-alpn
// because there's always at most 1 token cert per on-going domain authorization.
// See m.verify for details.
if cert := m.certTokens[name]; cert != nil {
return cert, nil
}
Expand Down Expand Up @@ -269,6 +286,17 @@ func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return cert, nil
}

// wantsTokenCert reports whether a TLS request with SNI is made by a CA server
// for a challenge verification.
func wantsTokenCert(hello *tls.ClientHelloInfo) bool {
// tls-alpn-01
if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto {
return true
}
// tls-sni-xx
return strings.HasSuffix(hello.ServerName, ".acme.invalid")
}

func supportsECDSA(hello *tls.ClientHelloInfo) bool {
// The "signature_algorithms" extension, if present, limits the key exchange
// algorithms allowed by the cipher suites. See RFC 5246, section 7.4.1.4.1.
Expand Down Expand Up @@ -635,7 +663,7 @@ func (m *Manager) revokePendingAuthz(ctx context.Context, uri []string) {
func (m *Manager) verify(ctx context.Context, client *acme.Client, domain string) error {
// The list of challenge types we'll try to fulfill
// in this specific order.
challengeTypes := []string{"tls-sni-02", "tls-sni-01"}
challengeTypes := []string{"tls-alpn-01", "tls-sni-02", "tls-sni-01"}
m.tokensMu.RLock()
if m.tryHTTP01 {
challengeTypes = append(challengeTypes, "http-01")
Expand Down Expand Up @@ -691,7 +719,7 @@ func (m *Manager) verify(ctx context.Context, client *acme.Client, domain string
}
return errors.New(errorMsg)
}
cleanup, err := m.fulfill(ctx, client, chal)
cleanup, err := m.fulfill(ctx, client, chal, domain)
if err != nil {
errs[chal] = err
continue
Expand All @@ -714,8 +742,15 @@ func (m *Manager) verify(ctx context.Context, client *acme.Client, domain string

// fulfill provisions a response to the challenge chal.
// The cleanup is non-nil only if provisioning succeeded.
func (m *Manager) fulfill(ctx context.Context, client *acme.Client, chal *acme.Challenge) (cleanup func(), err error) {
func (m *Manager) fulfill(ctx context.Context, client *acme.Client, chal *acme.Challenge, domain string) (cleanup func(), err error) {
switch chal.Type {
case "tls-alpn-01":
cert, err := client.TLSALPN01ChallengeCert(chal.Token, domain)
if err != nil {
return nil, err
}
m.putCertToken(ctx, domain, &cert)
return func() { go m.deleteCertToken(domain) }, nil
case "tls-sni-01":
cert, name, err := client.TLSSNI01ChallengeCert(chal.Token)
if err != nil {
Expand Down
63 changes: 61 additions & 2 deletions acme/autocert/autocert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"html/template"
"io"
"io/ioutil"
"math/big"
"net/http"
"net/http/httptest"
Expand All @@ -31,6 +32,7 @@ import (
"time"

"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert/internal/acmetest"
)

var (
Expand Down Expand Up @@ -440,6 +442,7 @@ func getCertificateFromManager(man *Manager, ecdsaSupport bool) func(string) err

// startACMEServerStub runs an ACME server
// The domain argument is the expected domain name of a certificate request.
// TODO: Drop this in favour of x/crypto/acme/autocert/internal/acmetest.
func startACMEServerStub(t *testing.T, getCertificate func(string) error, domain string) (url string, finish func()) {
// echo token-02 | shasum -a 256
// then divide result in 2 parts separated by dot
Expand Down Expand Up @@ -607,7 +610,7 @@ func TestVerifyHTTP01(t *testing.T) {
}

// ACME CA server stub, only the needed bits.
// TODO: Merge this with startACMEServerStub, making it a configurable CA for testing.
// TODO: Replace this with x/crypto/acme/autocert/internal/acmetest.
var ca *httptest.Server
ca = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "nonce")
Expand Down Expand Up @@ -701,7 +704,7 @@ func TestRevokeFailedAuthz(t *testing.T) {
done := make(chan struct{}) // closed when revokeCount is 3

// ACME CA server stub, only the needed bits.
// TODO: Merge this with startACMEServerStub, making it a configurable CA for testing.
// TODO: Replace this with x/crypto/acme/autocert/internal/acmetest.
var ca *httptest.Server
ca = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "nonce")
Expand Down Expand Up @@ -1128,3 +1131,59 @@ func TestSupportsECDSA(t *testing.T) {
}
}
}

// TODO: add same end-to-end for http-01 challenge type.
func TestEndToEnd(t *testing.T) {
const domain = "example.org"

// ACME CA server
ca := acmetest.NewCAServer([]string{"tls-alpn-01"}, []string{domain})
defer ca.Close()

// User dummy server.
m := &Manager{
Prompt: AcceptTOS,
Client: &acme.Client{DirectoryURL: ca.URL},
}
us := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}))
us.TLS = &tls.Config{
NextProtos: []string{"http/1.1", acme.ALPNProto},
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := m.GetCertificate(hello)
if err != nil {
t.Errorf("m.GetCertificate: %v", err)
}
return cert, err
},
}
us.StartTLS()
defer us.Close()
// In TLS-ALPN challenge verification, CA connects to the domain:443 in question.
// Because the domain won't resolve in tests, we need to tell the CA
// where to dial to instead.
ca.Resolve(domain, strings.TrimPrefix(us.URL, "https://"))

// A client visiting user dummy server.
tr := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: ca.Roots,
ServerName: domain,
},
}
client := &http.Client{Transport: tr}
res, err := client.Get(us.URL)
if err != nil {
t.Logf("CA errors: %v", ca.Errors())
t.Fatal(err)
}
defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if v := string(b); v != "OK" {
t.Errorf("user server response: %q; want 'OK'", v)
}
}
4 changes: 1 addition & 3 deletions acme/autocert/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
package autocert_test

import (
"crypto/tls"
"fmt"
"log"
"net/http"
Expand All @@ -27,10 +26,9 @@ func ExampleManager() {
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.org"),
}
go http.ListenAndServe(":http", m.HTTPHandler(nil))
s := &http.Server{
Addr: ":https",
TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
TLSConfig: m.TLSConfig(),
}
s.ListenAndServeTLS("", "")
}
Loading

0 comments on commit 2d1b218

Please sign in to comment.