#15: implement ACME certificate store, Traefik/Coolify cert watcher & secrets vault
This commit is contained in:
1 parent
769e0b59f0
commit
6ef427dc09
11 files changed
+1667
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
)
|
||||
|
||||
// TlsaPublisher publishes the DANE associations that must be live for the
|
||||
// mail host. The rollover hands it the complete desired set, so applying it is
|
||||
// idempotent.
|
||||
type TlsaPublisher interface {
|
||||
PublishTlsa(ctx context.Context, mailHost string, digests []string) error
|
||||
}
|
||||
|
||||
// PendingKeyStore is the part of the ACME repository the rollover needs
|
||||
// beyond the plain port: it tracks when an association was published.
|
||||
type PendingKeyStore interface {
|
||||
SavePendingKeyWithDigest(ctx context.Context, domain string, privateKeyPEM []byte, tlsaDigest string) error
|
||||
PendingKeyDigest(ctx context.Context, domain string) (string, *time.Time, error)
|
||||
MarkPendingKeyPublished(ctx context.Context, domain string) error
|
||||
}
|
||||
|
||||
// Manager keeps the mail host's certificate current and, when DANE is on,
|
||||
// drives the rollover so the published associations always cover whatever is
|
||||
// being served.
|
||||
type Manager struct {
|
||||
issuer *Issuer
|
||||
store port.CertificateStore
|
||||
pending PendingKeyStore
|
||||
rollover *usecase.RolloverTlsaUseCase
|
||||
tlsa TlsaPublisher
|
||||
|
||||
mailHost string
|
||||
extraHosts []string
|
||||
daneEnabled bool
|
||||
|
||||
current atomic.Pointer[tls.Certificate]
|
||||
}
|
||||
|
||||
func NewManager(
|
||||
issuer *Issuer,
|
||||
store port.CertificateStore,
|
||||
pending PendingKeyStore,
|
||||
tlsa TlsaPublisher,
|
||||
mailHost string,
|
||||
extraHosts []string,
|
||||
daneEnabled bool,
|
||||
) *Manager {
|
||||
return &Manager{
|
||||
issuer: issuer,
|
||||
store: store,
|
||||
pending: pending,
|
||||
rollover: usecase.NewRolloverTlsaUseCase(usecase.DefaultRolloverConfig()),
|
||||
tlsa: tlsa,
|
||||
mailHost: mailHost,
|
||||
extraHosts: extraHosts,
|
||||
daneEnabled: daneEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCertificate satisfies tls.Config.GetCertificate.
|
||||
func (m *Manager) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert := m.current.Load()
|
||||
if cert == nil {
|
||||
return nil, fmt.Errorf("acme: no certificate available for %s yet", m.mailHost)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Start loads whatever is stored, issues a certificate if there is none, and
|
||||
// then keeps reconciling on an interval.
|
||||
func (m *Manager) Start(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
|
||||
if err := m.reconcile(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.reconcile(ctx); err != nil {
|
||||
log.Printf("acme reconcile failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcile is the whole certificate lifecycle in one pass. It is written to
|
||||
// be safe to run at any moment: each step only acts when its precondition
|
||||
// holds, so an interrupted rollover simply resumes on the next tick.
|
||||
func (m *Manager) reconcile(ctx context.Context) error {
|
||||
stored, err := m.store.LoadCertificate(ctx, m.mailHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load stored certificate: %w", err)
|
||||
}
|
||||
|
||||
if stored != nil {
|
||||
if err := m.serve(stored); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !m.daneEnabled {
|
||||
// Without DANE there is no association to coordinate with, so the
|
||||
// only question is whether the certificate needs renewing.
|
||||
if stored == nil || m.rollover.ShouldRenewNow(stored.NotAfter) {
|
||||
return m.issue(ctx, nil, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.reconcileWithDane(ctx, stored)
|
||||
}
|
||||
|
||||
func (m *Manager) reconcileWithDane(ctx context.Context, stored *port.StoredCertificate) error {
|
||||
// A deployment with DANE and no certificate yet: issue against a
|
||||
// pre-generated key and publish its association immediately. There is no
|
||||
// old association to preserve, so no window to protect.
|
||||
if stored == nil {
|
||||
keyPEM, err := GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest, err := usecase.TlsaDigestForKey(keyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, []string{digest}); err != nil {
|
||||
return fmt.Errorf("publish initial TLSA: %w", err)
|
||||
}
|
||||
return m.issue(ctx, keyPEM, 1)
|
||||
}
|
||||
|
||||
currentDigest, err := usecase.TlsaDigestForCertificate(stored.CertificatePEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pendingDigest, publishedAt, err := m.pending.PendingKeyDigest(ctx, m.mailHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decision := m.rollover.Decide(ctx, usecase.RolloverState{
|
||||
CurrentDigest: currentDigest,
|
||||
NotAfter: stored.NotAfter,
|
||||
PendingDigest: pendingDigest,
|
||||
PendingPublishedAt: publishedAt,
|
||||
CertificateMatchesPending: pendingDigest != "" && pendingDigest == currentDigest,
|
||||
})
|
||||
|
||||
log.Printf("acme rollover for %s: %s (%s)", m.mailHost, decision.Stage, decision.Reason)
|
||||
|
||||
// The published set is always applied first. Publishing is additive and
|
||||
// idempotent, so doing it before anything else means the associations are
|
||||
// never behind the certificate.
|
||||
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, decision.Publish); err != nil {
|
||||
return fmt.Errorf("publish TLSA set: %w", err)
|
||||
}
|
||||
|
||||
switch decision.Stage {
|
||||
case usecase.StagePublishNext:
|
||||
if pendingDigest == "" {
|
||||
// Generate the next key and publish its association. The
|
||||
// certificate is not touched on this pass: the association has to
|
||||
// propagate first.
|
||||
keyPEM, err := GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest, err := usecase.TlsaDigestForKey(keyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.pending.SavePendingKeyWithDigest(ctx, m.mailHost, keyPEM, digest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.tlsa.PublishTlsa(ctx, m.mailHost, []string{currentDigest, digest}); err != nil {
|
||||
return fmt.Errorf("publish next TLSA: %w", err)
|
||||
}
|
||||
}
|
||||
return m.pending.MarkPendingKeyPublished(ctx, m.mailHost)
|
||||
|
||||
case usecase.StageRenew:
|
||||
keyPEM, err := m.store.LoadPendingKey(ctx, m.mailHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keyPEM == nil {
|
||||
return fmt.Errorf("acme: rollover reached RENEW with no pending key for %s", m.mailHost)
|
||||
}
|
||||
return m.issue(ctx, keyPEM, stored.KeyGeneration+1)
|
||||
|
||||
case usecase.StageStable:
|
||||
// The rollover finished and the old association has been dropped, so
|
||||
// the reserved key is no longer pending.
|
||||
if pendingDigest != "" && pendingDigest == currentDigest {
|
||||
return m.store.ClearPendingKey(ctx, m.mailHost)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
// AWAIT_PROPAGATION and CLEANUP are waits: the published set above is
|
||||
// the entire action.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// issue obtains a certificate and starts serving it.
|
||||
func (m *Manager) issue(ctx context.Context, privateKeyPEM []byte, generation int) error {
|
||||
domains := append([]string{m.mailHost}, m.extraHosts...)
|
||||
|
||||
issued, err := m.issuer.Obtain(ctx, domains, privateKeyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issued.KeyGeneration = generation
|
||||
|
||||
if err := m.store.SaveCertificate(ctx, *issued); err != nil {
|
||||
return fmt.Errorf("store issued certificate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("acme issued a certificate for %s, valid until %s", strings.Join(domains, ", "), issued.NotAfter.Format(time.RFC3339))
|
||||
return m.serve(issued)
|
||||
}
|
||||
|
||||
// serve parses a stored certificate and swaps it in atomically, so in-flight
|
||||
// handshakes are unaffected.
|
||||
func (m *Manager) serve(stored *port.StoredCertificate) error {
|
||||
pair, err := tls.X509KeyPair(stored.CertificatePEM, stored.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acme: stored certificate and key do not match: %w", err)
|
||||
}
|
||||
m.current.Store(&pair)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
var _ port.CertProvider = (*Manager)(nil)
|
||||
Reference in new issue
Block a user