237 lines
7.3 KiB
Go
237 lines
7.3 KiB
Go
// Package acme implements mode B of PLAN.md section 8.3: LambdaMail obtains
|
|
// and renews the mail host's certificate itself, instead of reading the one
|
|
// Traefik manages.
|
|
//
|
|
// The reason this mode exists at all is DANE. Traefik cannot be told to reuse
|
|
// a key we generated, and DANE requires exactly that: the TLSA association for
|
|
// the next certificate has to be published, and allowed to propagate, before
|
|
// that certificate is served. Without a pre-generated key there is always a
|
|
// window in which the published association does not match the certificate,
|
|
// and every validating receiver rejects mail during it (PLAN.md section 5.1).
|
|
package acme
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-acme/lego/v4/certcrypto"
|
|
"github.com/go-acme/lego/v4/certificate"
|
|
"github.com/go-acme/lego/v4/lego"
|
|
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
|
"github.com/go-acme/lego/v4/registration"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
)
|
|
|
|
// LetsEncryptProduction and LetsEncryptStaging are the two directories worth
|
|
// naming. Staging exists because the production rate limits are strict enough
|
|
// that a misconfigured deploy loop can lock a domain out for a week.
|
|
const (
|
|
LetsEncryptProduction = "https://acme-v02.api.letsencrypt.org/directory"
|
|
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
)
|
|
|
|
// Issuer obtains certificates over ACME with the DNS-01 challenge.
|
|
//
|
|
// DNS-01 is used rather than HTTP-01 because the mail host does not
|
|
// necessarily serve HTTP, and because it is the only challenge that can issue
|
|
// a wildcard.
|
|
type Issuer struct {
|
|
store port.CertificateStore
|
|
email string
|
|
directoryURL string
|
|
cloudflareToken string
|
|
}
|
|
|
|
func NewIssuer(store port.CertificateStore, email, directoryURL, cloudflareToken string) *Issuer {
|
|
if directoryURL == "" {
|
|
directoryURL = LetsEncryptProduction
|
|
}
|
|
return &Issuer{
|
|
store: store,
|
|
email: email,
|
|
directoryURL: directoryURL,
|
|
cloudflareToken: cloudflareToken,
|
|
}
|
|
}
|
|
|
|
// acmeUser adapts the stored account key to lego's registration.User.
|
|
type acmeUser struct {
|
|
email string
|
|
key crypto.PrivateKey
|
|
registration *registration.Resource
|
|
}
|
|
|
|
func (u *acmeUser) GetEmail() string { return u.email }
|
|
func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
|
|
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
|
|
|
// GeneratePrivateKey produces a key for the next certificate. It is exported
|
|
// because the rollover generates the key well before issuance, so that its
|
|
// TLSA association can be published first.
|
|
func GeneratePrivateKey() ([]byte, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate certificate key: %w", err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal certificate key: %w", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
|
|
}
|
|
|
|
// Obtain issues a certificate for the given domains.
|
|
//
|
|
// When privateKeyPEM is supplied the CSR is built against that key, which is
|
|
// what keeps a pre-published TLSA association valid. When it is nil a fresh
|
|
// key is generated, which is correct only for deployments without DANE.
|
|
func (i *Issuer) Obtain(ctx context.Context, domains []string, privateKeyPEM []byte) (*port.StoredCertificate, error) {
|
|
if len(domains) == 0 {
|
|
return nil, fmt.Errorf("acme: no domains requested")
|
|
}
|
|
|
|
client, err := i.client(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
request := certificate.ObtainRequest{
|
|
Domains: domains,
|
|
Bundle: true,
|
|
}
|
|
|
|
if privateKeyPEM != nil {
|
|
key, err := parsePrivateKey(privateKeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// This is the whole point of mode B: the CA signs a CSR built from a
|
|
// key we chose, so the association published in advance still matches.
|
|
request.PrivateKey = key
|
|
}
|
|
|
|
resource, err := client.Certificate.Obtain(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: obtain certificate for %v: %w", domains, err)
|
|
}
|
|
|
|
notAfter, err := certificateExpiry(resource.Certificate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &port.StoredCertificate{
|
|
Domain: domains[0],
|
|
CertificatePEM: resource.Certificate,
|
|
PrivateKeyPEM: resource.PrivateKey,
|
|
NotAfter: notAfter,
|
|
}, nil
|
|
}
|
|
|
|
// client builds a lego client, reusing the stored account key so that every
|
|
// restart does not register a new account with the CA.
|
|
func (i *Issuer) client(ctx context.Context) (*lego.Client, error) {
|
|
accountKeyPEM, err := i.store.LoadAccountKey(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: load account key: %w", err)
|
|
}
|
|
|
|
newAccount := accountKeyPEM == nil
|
|
if newAccount {
|
|
if accountKeyPEM, err = GeneratePrivateKey(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
accountKey, err := parsePrivateKey(accountKeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user := &acmeUser{email: i.email, key: accountKey}
|
|
|
|
config := lego.NewConfig(user)
|
|
config.CADirURL = i.directoryURL
|
|
config.Certificate.KeyType = certcrypto.EC256
|
|
|
|
client, err := lego.NewClient(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: create client: %w", err)
|
|
}
|
|
|
|
provider, err := cloudflare.NewDNSProviderConfig(&cloudflare.Config{
|
|
AuthToken: i.cloudflareToken,
|
|
// The challenge record has to be visible before the CA looks, so the
|
|
// provider polls until it can see it itself.
|
|
PropagationTimeout: 5 * time.Minute,
|
|
PollingInterval: 10 * time.Second,
|
|
TTL: 120,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: create cloudflare dns provider: %w", err)
|
|
}
|
|
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
|
|
return nil, fmt.Errorf("acme: configure dns-01 challenge: %w", err)
|
|
}
|
|
|
|
if newAccount {
|
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: register account: %w", err)
|
|
}
|
|
user.registration = reg
|
|
|
|
if err := i.store.SaveAccountKey(ctx, accountKeyPEM); err != nil {
|
|
return nil, fmt.Errorf("acme: save account key: %w", err)
|
|
}
|
|
} else {
|
|
reg, err := client.Registration.ResolveAccountByKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: resolve existing account: %w", err)
|
|
}
|
|
user.registration = reg
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func parsePrivateKey(pemBytes []byte) (crypto.PrivateKey, error) {
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("acme: private key is not valid PEM")
|
|
}
|
|
|
|
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
|
return key, nil
|
|
}
|
|
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
|
return key, nil
|
|
}
|
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme: parse private key: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// certificateExpiry reads the leaf's NotAfter, which is what drives renewal.
|
|
func certificateExpiry(chainPEM []byte) (time.Time, error) {
|
|
block, _ := pem.Decode(chainPEM)
|
|
if block == nil {
|
|
return time.Time{}, fmt.Errorf("acme: issued certificate is not valid PEM")
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("acme: parse issued certificate: %w", err)
|
|
}
|
|
return cert.NotAfter, nil
|
|
}
|