Skip to content
Snippets Groups Projects
acme.go 4.91 KiB
Newer Older
ale's avatar
ale committed
package acmeserver

import (
	"bytes"
	"context"
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"path/filepath"
	"strings"
	"sync"

	"golang.org/x/crypto/acme"
)

// ACME handles the certificate creation/renewal workflow using acme
// http-01 challenges. It serves validation tokens under the
// /.well-known/acme-challenge path when used as an HTTP handler.
type ACME struct {
	email          string
	accountKeyPath string
	client         *acme.Client

	mx         sync.Mutex
	httpTokens map[string][]byte
}

// NewACME returns a new ACME object with the provided config.
func NewACME(config *Config) (*ACME, error) {
	if config.Email == "" {
		return nil, errors.New("configuration parameter 'email' is unset")
	}

	return &ACME{
		email:          config.Email,
		accountKeyPath: filepath.Join(config.Dir, "account.key"),
		httpTokens:     make(map[string][]byte),
	}, nil
}

func (a *ACME) accountKey(ctx context.Context) (crypto.Signer, error) {
	if key, err := parsePrivateKeyFromFile(a.accountKeyPath); err == nil {
		return key, err
	}

	log.Printf("generating new account key")
	eckey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}
	var buf bytes.Buffer
	if err = encodeECDSAKey(&buf, eckey); err != nil {
		return nil, err
	}
	if err = ioutil.WriteFile(a.accountKeyPath, buf.Bytes(), 0600); err != nil {
		return nil, err
	}

	return eckey, err
}

func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
	if a.client != nil {
		return a.client, nil
	}

	client := &acme.Client{
		DirectoryURL: acme.LetsEncryptURL,
	}
	key, err := a.accountKey(ctx)
	if err != nil {
		return nil, err
	}
	client.Key = key
	ac := &acme.Account{
		Contact: []string{"mailto:" + a.email},
	}
	_, err = client.Register(ctx, ac, func(_ string) bool { return true })
	if ae, ok := err.(*acme.Error); err == nil || ok && ae.StatusCode == http.StatusConflict {
		a.client = client
		err = nil
	}
	return a.client, err
}

// GetCertificate returns a certificate chain (and the parsed leaf
// cert), given a private key and a list of domains. The first domain
// will be the subject CN, the others will be added as subjectAltNames.
func (a *ACME) GetCertificate(ctx context.Context, key crypto.Signer, domains []string) (der [][]byte, leaf *x509.Certificate, err error) {
	client, err := a.acmeClient(ctx)
	if err != nil {
		return nil, nil, err
	}

	if err = a.verifyAll(ctx, client, domains); err != nil {
		return nil, nil, err
	}

	csr, err := certRequest(key, domains)
	if err != nil {
		return nil, nil, err
	}
	der, _, err = client.CreateCert(ctx, csr, 0, true)
	if err != nil {
		return nil, nil, err
	}
	leaf, err = validCert(domains, der, key)
	if err != nil {
		return nil, nil, err
	}
	return der, leaf, nil
}

func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, domains []string) error {
	for _, domain := range domains {
		if err := a.verify(ctx, client, domain); err != nil {
			return err
		}
	}
	return nil
}

func (a *ACME) verify(ctx context.Context, client *acme.Client, domain string) error {
	authz, err := client.Authorize(ctx, domain)
	if err != nil {
		return err
	}

	switch authz.Status {
	case acme.StatusValid:
		return nil // already authorized
	case acme.StatusInvalid:
		return fmt.Errorf("invalid authorization %q", authz.URI)
	}

	chal := pickChallenge("http-01", authz.Challenges)
	if chal == nil {
		return fmt.Errorf("unable to authorize %q", domain)
	}
	cleanup, err := a.fulfill(ctx, client, chal)
	if err != nil {
		return err
	}
	defer cleanup()

	if _, err = client.Accept(ctx, chal); err != nil {
		return err
	}
	_, err = client.WaitAuthorization(ctx, authz.URI)
	return err
}

func (a *ACME) fulfill(ctx context.Context, client *acme.Client, chal *acme.Challenge) (cleanup func(), err error) {
	if chal.Type != "http-01" {
		return nil, errors.New("unsupported challenge type")
	}

	resp, err := client.HTTP01ChallengeResponse(chal.Token)
	if err != nil {
		return nil, err
	}
	p := client.HTTP01ChallengePath(chal.Token)
	a.putHTTPToken(ctx, p, resp)
	return func() { go a.deleteHTTPToken(p) }, nil
}

func (a *ACME) httpToken(ctx context.Context, tokenPath string) ([]byte, error) {
	a.mx.Lock()
	defer a.mx.Unlock()

	if v, ok := a.httpTokens[tokenPath]; ok {
		return v, nil
	}
	return nil, errors.New("token not found")
}

func (a *ACME) putHTTPToken(ctx context.Context, tokenPath, val string) {
	a.mx.Lock()
	a.httpTokens[tokenPath] = []byte(val)
	a.mx.Unlock()
}

func (a *ACME) deleteHTTPToken(tokenPath string) {
	a.mx.Lock()
	delete(a.httpTokens, tokenPath)
	a.mx.Unlock()
}

func (a *ACME) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
		http.NotFound(w, r)
		return
	}

	a.mx.Lock()
	defer a.mx.Unlock()

	data, err := a.httpToken(r.Context(), r.URL.Path)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}

	w.Write(data)
}