acme/autocert: add support for tls-alpn-01
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 <bradfitz@golang.org> Run-TryBot: Brad Fitzpatrick <bradfitz@golang.org> TryBot-Result: Gobot Gobot <gobot@golang.org>
This commit is contained in:
+11
-2
@@ -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}
|
||||
|
||||
+47
-12
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert/internal/acmetest"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package autocert_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -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("", "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package acmetest provides types for testing acme and autocert packages.
|
||||
//
|
||||
// TODO: Consider moving this to x/crypto/acme/internal/acmetest for acme tests as well.
|
||||
package acmetest
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CAServer is a simple test server which implements ACME spec bits needed for testing.
|
||||
type CAServer struct {
|
||||
URL string // server URL after it has been started
|
||||
Roots *x509.CertPool // CA root certificates; initialized in NewCAServer
|
||||
|
||||
rootKey crypto.Signer
|
||||
rootCert []byte // DER encoding
|
||||
rootTemplate *x509.Certificate
|
||||
|
||||
server *httptest.Server
|
||||
challengeTypes []string // supported challenge types
|
||||
domainsWhitelist []string // only these domains are valid for issuing, unless empty
|
||||
|
||||
mu sync.Mutex
|
||||
certCount int // number of issued certs
|
||||
domainAddr map[string]string // domain name to addr:port resolution
|
||||
authorizations map[string]*authorization // keyed by domain name
|
||||
errors []error // encountered client errors
|
||||
}
|
||||
|
||||
// NewCAServer creates a new ACME test server and starts serving requests.
|
||||
// The returned CAServer issues certs signed with the CA roots
|
||||
// available in the Roots field.
|
||||
//
|
||||
// The challengeTypes argument defines the supported ACME challenge types
|
||||
// sent to a client in a response for a domain authorization.
|
||||
// If domainsWhitelist is non-empty, the certs will be issued only for the specified
|
||||
// list of domains. Otherwise, any domain name is allowed.
|
||||
func NewCAServer(challengeTypes []string, domainsWhitelist []string) *CAServer {
|
||||
var whitelist []string
|
||||
for _, name := range domainsWhitelist {
|
||||
whitelist = append(whitelist, name)
|
||||
}
|
||||
sort.Strings(whitelist)
|
||||
ca := &CAServer{
|
||||
challengeTypes: challengeTypes,
|
||||
domainsWhitelist: whitelist,
|
||||
domainAddr: make(map[string]string),
|
||||
authorizations: make(map[string]*authorization),
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ecdsa.GenerateKey: %v", err))
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Acme Co"},
|
||||
CommonName: "Root CA",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("x509.CreateCertificate: %v", err))
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("x509.ParseCertificate: %v", err))
|
||||
}
|
||||
ca.Roots = x509.NewCertPool()
|
||||
ca.Roots.AddCert(cert)
|
||||
ca.rootKey = key
|
||||
ca.rootCert = der
|
||||
ca.rootTemplate = tmpl
|
||||
|
||||
ca.server = httptest.NewServer(http.HandlerFunc(ca.handle))
|
||||
ca.URL = ca.server.URL
|
||||
return ca
|
||||
}
|
||||
|
||||
// Close shuts down the server and blocks until all outstanding
|
||||
// requests on this server have completed.
|
||||
func (ca *CAServer) Close() {
|
||||
ca.server.Close()
|
||||
}
|
||||
|
||||
// Errors returns all client errors.
|
||||
func (ca *CAServer) Errors() []error {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
return ca.errors
|
||||
}
|
||||
|
||||
// Resolve adds a domain to address resolution for the ca to dial to
|
||||
// when validating challenges for the domain authorization.
|
||||
func (ca *CAServer) Resolve(domain, addr string) {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
ca.domainAddr[domain] = addr
|
||||
}
|
||||
|
||||
type discovery struct {
|
||||
NewReg string `json:"new-reg"`
|
||||
NewAuthz string `json:"new-authz"`
|
||||
NewCert string `json:"new-cert"`
|
||||
}
|
||||
|
||||
type challenge struct {
|
||||
URI string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type authorization struct {
|
||||
Status string `json:"status"`
|
||||
Challenges []challenge `json:"challenges"`
|
||||
|
||||
id int
|
||||
domain string
|
||||
}
|
||||
|
||||
func (ca *CAServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Replay-Nonce", "nonce")
|
||||
if r.Method == "HEAD" {
|
||||
// a nonce request
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Verify nonce header for all POST requests.
|
||||
|
||||
switch {
|
||||
default:
|
||||
err := fmt.Errorf("unrecognized r.URL.Path: %s", r.URL.Path)
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
||||
// Discovery request.
|
||||
case r.URL.Path == "/":
|
||||
resp := &discovery{
|
||||
NewReg: ca.serverURL("/new-reg"),
|
||||
NewAuthz: ca.serverURL("/new-authz"),
|
||||
NewCert: ca.serverURL("/new-cert"),
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
panic(fmt.Sprintf("discovery response: %v", err))
|
||||
}
|
||||
|
||||
// Client key registration request.
|
||||
case r.URL.Path == "/new-reg":
|
||||
// TODO: Check the user account key against a ca.accountKeys?
|
||||
w.Write([]byte("{}"))
|
||||
|
||||
// Domain authorization request.
|
||||
case r.URL.Path == "/new-authz":
|
||||
var req struct {
|
||||
Identifier struct{ Value string }
|
||||
}
|
||||
if err := decodePayload(&req, r.Body); err != nil {
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
authz, ok := ca.authorizations[req.Identifier.Value]
|
||||
if !ok {
|
||||
authz = &authorization{
|
||||
domain: req.Identifier.Value,
|
||||
Status: "pending",
|
||||
}
|
||||
for _, typ := range ca.challengeTypes {
|
||||
authz.Challenges = append(authz.Challenges, challenge{
|
||||
Type: typ,
|
||||
URI: ca.serverURL("/challenge/%s/%s", typ, authz.domain),
|
||||
Token: challengeToken(authz.domain, typ),
|
||||
})
|
||||
}
|
||||
ca.authorizations[authz.domain] = authz
|
||||
}
|
||||
w.Header().Set("Location", ca.serverURL("/authz/%s", authz.domain))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(authz); err != nil {
|
||||
panic(fmt.Sprintf("new authz response: %v", err))
|
||||
}
|
||||
|
||||
// Accept tls-alpn-01 challenge type requests.
|
||||
// TODO: Add http-01 and dns-01 handlers.
|
||||
case strings.HasPrefix(r.URL.Path, "/challenge/tls-alpn-01/"):
|
||||
domain := strings.TrimPrefix(r.URL.Path, "/challenge/tls-alpn-01/")
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
if _, ok := ca.authorizations[domain]; !ok {
|
||||
err := fmt.Errorf("challenge accept: no authz for %q", domain)
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
go func(domain string) {
|
||||
err := ca.verifyALPNChallenge(domain)
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
authz := ca.authorizations[domain]
|
||||
if err != nil {
|
||||
authz.Status = "invalid"
|
||||
return
|
||||
}
|
||||
authz.Status = "valid"
|
||||
|
||||
}(domain)
|
||||
w.Write([]byte("{}"))
|
||||
|
||||
// Get authorization status requests.
|
||||
case strings.HasPrefix(r.URL.Path, "/authz/"):
|
||||
domain := strings.TrimPrefix(r.URL.Path, "/authz/")
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
authz, ok := ca.authorizations[domain]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("no authz for %q", domain), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(authz); err != nil {
|
||||
panic(fmt.Sprintf("get authz for %q response: %v", domain, err))
|
||||
}
|
||||
|
||||
// Cert issuance request.
|
||||
case r.URL.Path == "/new-cert":
|
||||
var req struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
decodePayload(&req, r.Body)
|
||||
b, _ := base64.RawURLEncoding.DecodeString(req.CSR)
|
||||
csr, err := x509.ParseCertificateRequest(b)
|
||||
if err != nil {
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
names := unique(append(csr.DNSNames, csr.Subject.CommonName))
|
||||
if err := ca.matchWhitelist(names); err != nil {
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := ca.authorized(names); err != nil {
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
der, err := ca.leafCert(csr)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("new-cert response: ca.leafCert: %v", err)
|
||||
ca.addError(err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=up", ca.serverURL("/ca-cert")))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write(der)
|
||||
|
||||
// CA chain cert request.
|
||||
case r.URL.Path == "/ca-cert":
|
||||
w.Write(ca.rootCert)
|
||||
}
|
||||
}
|
||||
|
||||
func (ca *CAServer) addError(err error) {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
ca.errors = append(ca.errors, err)
|
||||
}
|
||||
|
||||
func (ca *CAServer) serverURL(format string, arg ...interface{}) string {
|
||||
return ca.server.URL + fmt.Sprintf(format, arg...)
|
||||
}
|
||||
|
||||
func (ca *CAServer) matchWhitelist(dnsNames []string) error {
|
||||
if len(ca.domainsWhitelist) == 0 {
|
||||
return nil
|
||||
}
|
||||
var nomatch []string
|
||||
for _, name := range dnsNames {
|
||||
i := sort.SearchStrings(ca.domainsWhitelist, name)
|
||||
if i == len(ca.domainsWhitelist) || ca.domainsWhitelist[i] != name {
|
||||
nomatch = append(nomatch, name)
|
||||
}
|
||||
}
|
||||
if len(nomatch) > 0 {
|
||||
return fmt.Errorf("matchWhitelist: some domains don't match: %q", nomatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ca *CAServer) authorized(dnsNames []string) error {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
var noauthz []string
|
||||
for _, name := range dnsNames {
|
||||
authz, ok := ca.authorizations[name]
|
||||
if !ok || authz.Status != "valid" {
|
||||
noauthz = append(noauthz, name)
|
||||
}
|
||||
}
|
||||
if len(noauthz) > 0 {
|
||||
return fmt.Errorf("CAServer: no authz for %q", noauthz)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ca *CAServer) leafCert(csr *x509.CertificateRequest) (der []byte, err error) {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
ca.certCount++ // next leaf cert serial number
|
||||
leaf := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(int64(ca.certCount)),
|
||||
Subject: pkix.Name{Organization: []string{"Test Acme Co"}},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: csr.DNSNames,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
if len(csr.DNSNames) == 0 {
|
||||
leaf.DNSNames = []string{csr.Subject.CommonName}
|
||||
}
|
||||
return x509.CreateCertificate(rand.Reader, leaf, ca.rootTemplate, csr.PublicKey, ca.rootKey)
|
||||
}
|
||||
|
||||
func (ca *CAServer) addr(domain string) (string, error) {
|
||||
ca.mu.Lock()
|
||||
defer ca.mu.Unlock()
|
||||
addr, ok := ca.domainAddr[domain]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("CAServer: no addr resolution for %q", domain)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
func (ca *CAServer) verifyALPNChallenge(domain string) error {
|
||||
const acmeALPNProto = "acme-tls/1"
|
||||
|
||||
addr, err := ca.addr(domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
ServerName: domain,
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{acmeALPNProto},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v := conn.ConnectionState().NegotiatedProtocol; v != acmeALPNProto {
|
||||
return fmt.Errorf("CAServer: verifyALPNChallenge: negotiated proto is %q; want %q", v, acmeALPNProto)
|
||||
}
|
||||
if n := len(conn.ConnectionState().PeerCertificates); n != 1 {
|
||||
return fmt.Errorf("len(PeerCertificates) = %d; want 1", n)
|
||||
}
|
||||
// TODO: verify conn.ConnectionState().PeerCertificates[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePayload(v interface{}, r io.Reader) error {
|
||||
var req struct{ Payload string }
|
||||
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(req.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(payload, v)
|
||||
}
|
||||
|
||||
func challengeToken(domain, challType string) string {
|
||||
return fmt.Sprintf("token-%s-%s", domain, challType)
|
||||
}
|
||||
|
||||
func unique(a []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var res []string
|
||||
for _, s := range a {
|
||||
if s != "" && !seen[s] {
|
||||
seen[s] = true
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -72,11 +72,8 @@ func NewListener(domains ...string) net.Listener {
|
||||
// the Manager m's Prompt, Cache, HostPolicy, and other desired options.
|
||||
func (m *Manager) Listener() net.Listener {
|
||||
ln := &listener{
|
||||
m: m,
|
||||
conf: &tls.Config{
|
||||
GetCertificate: m.GetCertificate, // bonus: panic on nil m
|
||||
NextProtos: []string{"h2", "http/1.1"}, // Enable HTTP/2
|
||||
},
|
||||
m: m,
|
||||
conf: m.TLSConfig(),
|
||||
}
|
||||
ln.tcpListener, ln.tcpListenErr = net.Listen("tcp", ":443")
|
||||
return ln
|
||||
|
||||
Reference in New Issue
Block a user