#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,3 @@
|
|||||||
|
DROP TABLE IF EXISTS acme_pending_keys;
|
||||||
|
DROP TABLE IF EXISTS acme_certificates;
|
||||||
|
DROP TABLE IF EXISTS acme_accounts;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Self-managed ACME state (PLAN.md section 8.3, mode B).
|
||||||
|
--
|
||||||
|
-- This lives in Postgres rather than on disk so that recreating the container
|
||||||
|
-- keeps the account and the issued certificates. Losing them would force a
|
||||||
|
-- re-registration and a re-issuance on every deploy, which reaches the CA's
|
||||||
|
-- rate limits quickly.
|
||||||
|
--
|
||||||
|
-- Every secret column is encrypted with the same KEK as the DKIM keys: these
|
||||||
|
-- are reusable secrets, so they are sealed, never hashed.
|
||||||
|
|
||||||
|
CREATE TABLE acme_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- One account per directory URL, so staging and production coexist.
|
||||||
|
directory_url VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255),
|
||||||
|
registration_uri TEXT,
|
||||||
|
private_key_enc BYTEA NOT NULL,
|
||||||
|
private_key_nonce BYTEA NOT NULL,
|
||||||
|
key_version INT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE acme_certificates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain CITEXT NOT NULL UNIQUE,
|
||||||
|
certificate_pem TEXT NOT NULL,
|
||||||
|
private_key_enc BYTEA NOT NULL,
|
||||||
|
private_key_nonce BYTEA NOT NULL,
|
||||||
|
key_version INT NOT NULL DEFAULT 1,
|
||||||
|
-- Increments on every key change. A TLSA record published for a
|
||||||
|
-- generation must outlive every certificate of that generation
|
||||||
|
-- (RFC 7671 section 8.1).
|
||||||
|
key_generation INT NOT NULL DEFAULT 1,
|
||||||
|
not_after TIMESTAMPTZ NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_acme_certificates_expiry ON acme_certificates(not_after);
|
||||||
|
|
||||||
|
-- The key reserved for the next issuance. It exists ahead of the certificate
|
||||||
|
-- precisely so its TLSA association can be published and allowed to propagate
|
||||||
|
-- before the certificate that uses it is served.
|
||||||
|
CREATE TABLE acme_pending_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain CITEXT NOT NULL UNIQUE,
|
||||||
|
private_key_enc BYTEA NOT NULL,
|
||||||
|
private_key_nonce BYTEA NOT NULL,
|
||||||
|
key_version INT NOT NULL DEFAULT 1,
|
||||||
|
-- SHA-256 of the SubjectPublicKeyInfo, which is the "3 1 1" TLSA value
|
||||||
|
-- for this key. Stored so the rollover can publish it without re-deriving.
|
||||||
|
tlsa_sha256 CHAR(64) NOT NULL,
|
||||||
|
-- When the association was published; the rollover waits out two TTLs
|
||||||
|
-- from here before switching the certificate over.
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoredCertificate is one issued certificate together with the key it was
|
||||||
|
// issued for. PLAN.md section 8.3 keeps these in Postgres so that recreating
|
||||||
|
// the container does not lose the account or force a re-issuance, which would
|
||||||
|
// quickly hit the CA's rate limits.
|
||||||
|
type StoredCertificate struct {
|
||||||
|
Domain string
|
||||||
|
// CertificatePEM is the full chain, leaf first.
|
||||||
|
CertificatePEM []byte
|
||||||
|
PrivateKeyPEM []byte
|
||||||
|
// NotAfter drives renewal scheduling.
|
||||||
|
NotAfter time.Time
|
||||||
|
// KeyGeneration increments on every key change. DANE needs it: the TLSA
|
||||||
|
// record published for a generation must stay live until no certificate
|
||||||
|
// of that generation is in use (RFC 7671 section 8.1).
|
||||||
|
KeyGeneration int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateStore persists ACME state. Everything it holds is secret, so an
|
||||||
|
// implementation is expected to seal it at rest.
|
||||||
|
type CertificateStore interface {
|
||||||
|
// LoadCertificate returns nil, nil when nothing is stored yet.
|
||||||
|
LoadCertificate(ctx context.Context, domain string) (*StoredCertificate, error)
|
||||||
|
SaveCertificate(ctx context.Context, cert StoredCertificate) error
|
||||||
|
|
||||||
|
// LoadAccountKey returns the ACME account key, or nil when the deployment
|
||||||
|
// has not registered with the CA yet.
|
||||||
|
LoadAccountKey(ctx context.Context) ([]byte, error)
|
||||||
|
SaveAccountKey(ctx context.Context, privateKeyPEM []byte) error
|
||||||
|
|
||||||
|
// LoadPendingKey returns the pre-generated key reserved for the next
|
||||||
|
// issuance. Pre-generating it is what makes a TLSA rollover possible: the
|
||||||
|
// association for the next key can be published before the certificate
|
||||||
|
// that uses it exists (PLAN.md section 5.1).
|
||||||
|
LoadPendingKey(ctx context.Context, domain string) ([]byte, error)
|
||||||
|
SavePendingKey(ctx context.Context, domain string, privateKeyPEM []byte) error
|
||||||
|
ClearPendingKey(ctx context.Context, domain string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/hex"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
|
)
|
||||||
|
|
||||||
|
func selfSignedCert(t *testing.T) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "mx.example.test"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse certificate: %v", err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAN.md section 5 / RFC 8461 section 2: DANE outranks MTA-STS.
|
||||||
|
func TestTLSPolicy_DaneTakesPrecedenceOverMtaSts(t *testing.T) {
|
||||||
|
if got := NewTLSPolicy(true, true).Effective(); got != TLSModeDane {
|
||||||
|
t.Errorf("Effective = %q, want dane", got)
|
||||||
|
}
|
||||||
|
if got := NewTLSPolicy(false, true).Effective(); got != TLSModeMtaSts {
|
||||||
|
t.Errorf("Effective = %q, want mta-sts", got)
|
||||||
|
}
|
||||||
|
if got := NewTLSPolicy(false, false).Effective(); got != TLSModeOpportunistic {
|
||||||
|
t.Errorf("Effective = %q, want opportunistic", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A destination publishing no policy must not be forced through validation;
|
||||||
|
// RFC 7435 prefers unauthenticated encryption to refusing the mail.
|
||||||
|
func TestTLSPolicy_OpportunisticDoesNotRequireValidation(t *testing.T) {
|
||||||
|
if NewTLSPolicy(false, false).RequiresValidation() {
|
||||||
|
t.Error("an opportunistic destination must not require validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "testing" mode exists precisely so that a broken TLS setup reports instead
|
||||||
|
// of losing mail (RFC 8461 section 5.2).
|
||||||
|
func TestTLSPolicy_MtaStsTestingDoesNotEnforce(t *testing.T) {
|
||||||
|
testing_ := NewMtaStsPolicy([]string{"mx.example.test"}, false)
|
||||||
|
if testing_.RequiresValidation() {
|
||||||
|
t.Error("mode: testing must not enforce")
|
||||||
|
}
|
||||||
|
|
||||||
|
enforcing := NewMtaStsPolicy([]string{"mx.example.test"}, true)
|
||||||
|
if !enforcing.RequiresValidation() {
|
||||||
|
t.Error("mode: enforce must require validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DANE is authenticated by DNSSEC, so it always enforces (RFC 7672 section 2.2).
|
||||||
|
func TestTLSPolicy_DaneAlwaysEnforces(t *testing.T) {
|
||||||
|
if !NewDanePolicy(nil).RequiresValidation() {
|
||||||
|
t.Error("a DANE policy must always require validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8461 section 4.1: the wildcard covers exactly one label.
|
||||||
|
func TestTLSPolicy_CoversHostAppliesSingleLabelWildcard(t *testing.T) {
|
||||||
|
policy := NewMtaStsPolicy([]string{"*.example.test", "mail.other.test"}, true)
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"mx1.example.test": true,
|
||||||
|
"MX1.EXAMPLE.TEST": true,
|
||||||
|
"mail.other.test": true,
|
||||||
|
"a.mx1.example.test": false,
|
||||||
|
"example.test": false,
|
||||||
|
"mx1.evil.test": false,
|
||||||
|
"mail.other.test.evil": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for host, want := range cases {
|
||||||
|
if got := policy.CoversHost(host); got != want {
|
||||||
|
t.Errorf("CoversHost(%q) = %v, want %v", host, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An opportunistic policy constrains no host.
|
||||||
|
func TestTLSPolicy_OpportunisticCoversEveryHost(t *testing.T) {
|
||||||
|
if !NewTLSPolicy(false, false).CoversHost("anything.test") {
|
||||||
|
t.Error("an opportunistic policy must not restrict the MX set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "3 1 1" association matches the SHA-256 of the SubjectPublicKeyInfo
|
||||||
|
// (RFC 6698 section 2.1.2).
|
||||||
|
func TestTLSPolicy_MatchesCertificateAgainstTlsaDigest(t *testing.T) {
|
||||||
|
cert := selfSignedCert(t)
|
||||||
|
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||||
|
|
||||||
|
record, err := valueobject.NewTLSARecord(3, 1, 1, hex.EncodeToString(sum[:]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTLSARecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := NewDanePolicy([]valueobject.TLSARecord{record})
|
||||||
|
if !policy.MatchesCertificate([]*x509.Certificate{cert}) {
|
||||||
|
t.Error("the certificate should match its own published digest")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different certificate must not match - this is the whole point.
|
||||||
|
if policy.MatchesCertificate([]*x509.Certificate{selfSignedCert(t)}) {
|
||||||
|
t.Error("an unrelated certificate matched the TLSA record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// During a rollover two associations are published; either one must satisfy
|
||||||
|
// the check (RFC 7671 section 8.1, PLAN.md section 5.1).
|
||||||
|
func TestTLSPolicy_MatchesEitherRolloverRecord(t *testing.T) {
|
||||||
|
current, next := selfSignedCert(t), selfSignedCert(t)
|
||||||
|
|
||||||
|
digest := func(c *x509.Certificate) string {
|
||||||
|
sum := sha256.Sum256(c.RawSubjectPublicKeyInfo)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRecord, _ := valueobject.NewTLSARecord(3, 1, 1, digest(current))
|
||||||
|
nextRecord, _ := valueobject.NewTLSARecord(3, 1, 1, digest(next))
|
||||||
|
policy := NewDanePolicy([]valueobject.TLSARecord{currentRecord, nextRecord})
|
||||||
|
|
||||||
|
if !policy.MatchesCertificate([]*x509.Certificate{current}) {
|
||||||
|
t.Error("the current certificate must still validate during a rollover")
|
||||||
|
}
|
||||||
|
if !policy.MatchesCertificate([]*x509.Certificate{next}) {
|
||||||
|
t.Error("the next certificate must already validate during a rollover")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSPolicy_MatchesCertificateRejectsEmptyInputs(t *testing.T) {
|
||||||
|
record, _ := valueobject.NewTLSARecord(3, 1, 1, hex.EncodeToString(make([]byte, 32)))
|
||||||
|
|
||||||
|
if NewDanePolicy([]valueobject.TLSARecord{record}).MatchesCertificate(nil) {
|
||||||
|
t.Error("an empty chain must not validate")
|
||||||
|
}
|
||||||
|
if NewDanePolicy(nil).MatchesCertificate([]*x509.Certificate{selfSignedCert(t)}) {
|
||||||
|
t.Error("a policy with no associations must not validate anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcmeRepository stores self-managed ACME state with every private key sealed
|
||||||
|
// (PLAN.md section 8.3).
|
||||||
|
type AcmeRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
vault *vault.SecretVault
|
||||||
|
directoryURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAcmeRepository(pool *pgxpool.Pool, v *vault.SecretVault, directoryURL string) *AcmeRepository {
|
||||||
|
return &AcmeRepository{pool: pool, vault: v, directoryURL: directoryURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) LoadCertificate(ctx context.Context, domain string) (*port.StoredCertificate, error) {
|
||||||
|
var (
|
||||||
|
cert port.StoredCertificate
|
||||||
|
certPEM string
|
||||||
|
enc, nonce []byte
|
||||||
|
keyVersion int
|
||||||
|
keyGeneration int
|
||||||
|
notAfter time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT certificate_pem, private_key_enc, private_key_nonce, key_version, key_generation, not_after
|
||||||
|
FROM acme_certificates WHERE domain = $1
|
||||||
|
`, domain).Scan(&certPEM, &enc, &nonce, &keyVersion, &keyGeneration, ¬After)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load certificate for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := r.vault.Open(enc, nonce, keyVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open certificate key for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert.Domain = domain
|
||||||
|
cert.CertificatePEM = []byte(certPEM)
|
||||||
|
cert.PrivateKeyPEM = privateKey
|
||||||
|
cert.KeyGeneration = keyGeneration
|
||||||
|
cert.NotAfter = notAfter
|
||||||
|
return &cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) SaveCertificate(ctx context.Context, cert port.StoredCertificate) error {
|
||||||
|
enc, nonce, err := r.vault.Seal(cert.PrivateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal certificate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO acme_certificates (
|
||||||
|
domain, certificate_pem, private_key_enc, private_key_nonce,
|
||||||
|
key_version, key_generation, not_after
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (domain) DO UPDATE SET
|
||||||
|
certificate_pem = EXCLUDED.certificate_pem,
|
||||||
|
private_key_enc = EXCLUDED.private_key_enc,
|
||||||
|
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||||
|
key_version = EXCLUDED.key_version,
|
||||||
|
key_generation = EXCLUDED.key_generation,
|
||||||
|
not_after = EXCLUDED.not_after,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, cert.Domain, string(cert.CertificatePEM), enc, nonce, vault.CurrentKeyVersion, cert.KeyGeneration, cert.NotAfter)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save certificate for %s: %w", cert.Domain, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) LoadAccountKey(ctx context.Context) ([]byte, error) {
|
||||||
|
var (
|
||||||
|
enc, nonce []byte
|
||||||
|
keyVersion int
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT private_key_enc, private_key_nonce, key_version
|
||||||
|
FROM acme_accounts WHERE directory_url = $1
|
||||||
|
`, r.directoryURL).Scan(&enc, &nonce, &keyVersion)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load acme account key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vault.Open(enc, nonce, keyVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) SaveAccountKey(ctx context.Context, privateKeyPEM []byte) error {
|
||||||
|
enc, nonce, err := r.vault.Seal(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal acme account key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO acme_accounts (directory_url, private_key_enc, private_key_nonce, key_version)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (directory_url) DO UPDATE SET
|
||||||
|
private_key_enc = EXCLUDED.private_key_enc,
|
||||||
|
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||||
|
key_version = EXCLUDED.key_version
|
||||||
|
`, r.directoryURL, enc, nonce, vault.CurrentKeyVersion)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save acme account key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) LoadPendingKey(ctx context.Context, domain string) ([]byte, error) {
|
||||||
|
var (
|
||||||
|
enc, nonce []byte
|
||||||
|
keyVersion int
|
||||||
|
)
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT private_key_enc, private_key_nonce, key_version
|
||||||
|
FROM acme_pending_keys WHERE domain = $1
|
||||||
|
`, domain).Scan(&enc, &nonce, &keyVersion)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load pending key for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vault.Open(enc, nonce, keyVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePendingKey stores the key reserved for the next issuance together with
|
||||||
|
// its TLSA digest, so the rollover can publish the association without having
|
||||||
|
// to re-derive it.
|
||||||
|
func (r *AcmeRepository) SavePendingKey(ctx context.Context, domain string, privateKeyPEM []byte) error {
|
||||||
|
return r.SavePendingKeyWithDigest(ctx, domain, privateKeyPEM, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) SavePendingKeyWithDigest(ctx context.Context, domain string, privateKeyPEM []byte, tlsaDigest string) error {
|
||||||
|
enc, nonce, err := r.vault.Seal(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal pending key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO acme_pending_keys (domain, private_key_enc, private_key_nonce, key_version, tlsa_sha256)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (domain) DO UPDATE SET
|
||||||
|
private_key_enc = EXCLUDED.private_key_enc,
|
||||||
|
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||||
|
key_version = EXCLUDED.key_version,
|
||||||
|
tlsa_sha256 = EXCLUDED.tlsa_sha256,
|
||||||
|
published_at = NULL
|
||||||
|
`, domain, enc, nonce, vault.CurrentKeyVersion, tlsaDigest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save pending key for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingKeyDigest returns the TLSA digest of the reserved key and when its
|
||||||
|
// association was published, or empty values when there is no pending key.
|
||||||
|
func (r *AcmeRepository) PendingKeyDigest(ctx context.Context, domain string) (digest string, publishedAt *time.Time, err error) {
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
SELECT tlsa_sha256, published_at FROM acme_pending_keys WHERE domain = $1
|
||||||
|
`, domain).Scan(&digest, &publishedAt)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("load pending key digest for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
return digest, publishedAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPendingKeyPublished records that the association is now in DNS, which
|
||||||
|
// starts the propagation wait.
|
||||||
|
func (r *AcmeRepository) MarkPendingKeyPublished(ctx context.Context, domain string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE acme_pending_keys SET published_at = NOW() WHERE domain = $1 AND published_at IS NULL
|
||||||
|
`, domain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark pending key published for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AcmeRepository) ClearPendingKey(ctx context.Context, domain string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM acme_pending_keys WHERE domain = $1`, domain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear pending key for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure port interface compliance
|
||||||
|
var _ port.CertificateStore = (*AcmeRepository)(nil)
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package tlsprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcmeCertWatcher serves the certificates Traefik obtained, reading them from
|
||||||
|
// the acme.json it maintains (PLAN.md section 8.1).
|
||||||
|
//
|
||||||
|
// Three properties of that file drive this design (section 8.2):
|
||||||
|
//
|
||||||
|
// 1. Traefik replaces acme.json by writing a temporary file and renaming it.
|
||||||
|
// A watcher bound to the original inode goes permanently blind after the
|
||||||
|
// first renewal, so this watcher polls the path instead and compares a
|
||||||
|
// content hash. The symptom of getting this wrong only appears ninety
|
||||||
|
// days later, in production, as an expired certificate.
|
||||||
|
// 2. Traefik only holds certificates for hosts it routes. A missing entry is
|
||||||
|
// a deployment problem, reported loudly rather than papered over.
|
||||||
|
// 3. The file layout varies by Traefik version and resolver name, so the
|
||||||
|
// parser iterates every resolver and tolerates unknown fields.
|
||||||
|
type AcmeCertWatcher struct {
|
||||||
|
path string
|
||||||
|
defaultHost string
|
||||||
|
|
||||||
|
// certificates is swapped atomically so that in-flight handshakes keep
|
||||||
|
// using a consistent map.
|
||||||
|
certificates atomic.Pointer[map[string]*tls.Certificate]
|
||||||
|
|
||||||
|
lastHash atomic.Pointer[[32]byte]
|
||||||
|
lastReloadAt atomic.Pointer[time.Time]
|
||||||
|
|
||||||
|
// unknownSNI counts handshakes for names we hold no certificate for; it
|
||||||
|
// backs the tls_unknown_sni_total metric of PLAN.md section 8.4.
|
||||||
|
unknownSNI atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoCertificatesLoaded = errors.New("acme watcher: no certificates loaded")
|
||||||
|
ErrNoCertificateForSNI = errors.New("acme watcher: no certificate for the requested server name")
|
||||||
|
)
|
||||||
|
|
||||||
|
// acmeFile mirrors the parts of acme.json this code reads. Every other field
|
||||||
|
// is ignored on purpose so a Traefik upgrade does not break certificate
|
||||||
|
// loading.
|
||||||
|
type acmeFile map[string]struct {
|
||||||
|
Certificates []struct {
|
||||||
|
Domain struct {
|
||||||
|
Main string `json:"main"`
|
||||||
|
SANs []string `json:"sans"`
|
||||||
|
} `json:"domain"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
} `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAcmeCertWatcher reads the store once so that a broken configuration
|
||||||
|
// fails at startup rather than at the first connection.
|
||||||
|
func NewAcmeCertWatcher(dir, filename, defaultHost string) (*AcmeCertWatcher, error) {
|
||||||
|
if filename == "" {
|
||||||
|
filename = "acme.json"
|
||||||
|
}
|
||||||
|
w := &AcmeCertWatcher{
|
||||||
|
path: filepath.Join(dir, filename),
|
||||||
|
defaultHost: strings.ToLower(defaultHost),
|
||||||
|
}
|
||||||
|
if err := w.reload(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch reloads on a fixed interval. Polling is the mechanism, not a fallback:
|
||||||
|
// see the rename note on the type.
|
||||||
|
func (w *AcmeCertWatcher) Watch(stop <-chan struct{}, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 60 * time.Second
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := w.reload(); err != nil {
|
||||||
|
// Keeping the previous store is deliberate: a truncated file
|
||||||
|
// caught mid-write must not leave the listeners with nothing
|
||||||
|
// (PLAN.md section 8.4).
|
||||||
|
log.Printf("WARNING: acme watcher reload failed, keeping the certificates already loaded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *AcmeCertWatcher) reload() error {
|
||||||
|
raw, err := os.ReadFile(w.path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", w.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(raw)
|
||||||
|
if previous := w.lastHash.Load(); previous != nil && *previous == hash {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates, err := parseAcmeStore(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(certificates) == 0 {
|
||||||
|
return fmt.Errorf("acme watcher: %s holds no usable certificate", w.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.certificates.Store(&certificates)
|
||||||
|
w.lastHash.Store(&hash)
|
||||||
|
now := time.Now()
|
||||||
|
w.lastReloadAt.Store(&now)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAcmeStore walks every resolver in the file. A single unusable entry -
|
||||||
|
// an expired certificate, or one whose key does not match - is skipped rather
|
||||||
|
// than failing the whole load, so one stale domain cannot take the server down.
|
||||||
|
func parseAcmeStore(raw []byte) (map[string]*tls.Certificate, error) {
|
||||||
|
var store acmeFile
|
||||||
|
if err := json.Unmarshal(raw, &store); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse acme store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates := map[string]*tls.Certificate{}
|
||||||
|
|
||||||
|
for resolverName, resolver := range store {
|
||||||
|
for _, entry := range resolver.Certificates {
|
||||||
|
certPEM, err := base64.StdEncoding.DecodeString(entry.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("acme watcher: skipping %s in resolver %q: certificate is not valid base64", entry.Domain.Main, resolverName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyPEM, err := base64.StdEncoding.DecodeString(entry.Key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("acme watcher: skipping %s in resolver %q: key is not valid base64", entry.Domain.Main, resolverName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("acme watcher: skipping %s in resolver %q: %v", entry.Domain.Main, resolverName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range append([]string{entry.Domain.Main}, entry.Domain.SANs...) {
|
||||||
|
if name != "" {
|
||||||
|
certificates[strings.ToLower(name)] = &pair
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCertificate satisfies tls.Config.GetCertificate. It matches the exact
|
||||||
|
// name, then the wildcard covering it, then the mail host, which is what a
|
||||||
|
// client connecting by IP or with no SNI should receive.
|
||||||
|
func (w *AcmeCertWatcher) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
loaded := w.certificates.Load()
|
||||||
|
if loaded == nil {
|
||||||
|
return nil, ErrNoCertificatesLoaded
|
||||||
|
}
|
||||||
|
certificates := *loaded
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
if hello != nil {
|
||||||
|
name = strings.ToLower(strings.TrimSuffix(hello.ServerName, "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cert, ok := certificates[name]; ok && name != "" {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
if cert, ok := certificates[wildcardOf(name)]; ok {
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
if cert, ok := certificates[w.defaultHost]; ok {
|
||||||
|
w.unknownSNI.Add(1)
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.unknownSNI.Add(1)
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrNoCertificateForSNI, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wildcardOf turns "mail.example.com" into "*.example.com".
|
||||||
|
func wildcardOf(name string) string {
|
||||||
|
if _, rest, found := strings.Cut(name, "."); found && rest != "" {
|
||||||
|
return "*." + rest
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastReload reports when the store was last successfully refreshed. A
|
||||||
|
// timestamp that stops advancing is the signal that the watcher went blind
|
||||||
|
// (PLAN.md risk R2).
|
||||||
|
func (w *AcmeCertWatcher) LastReload() time.Time {
|
||||||
|
if t := w.lastReloadAt.Load(); t != nil {
|
||||||
|
return *t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnknownSNICount backs the tls_unknown_sni_total metric.
|
||||||
|
func (w *AcmeCertWatcher) UnknownSNICount() int64 {
|
||||||
|
return w.unknownSNI.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCertificateFor reports whether a certificate exists for a host. The
|
||||||
|
// preflight uses it to catch the missing-router trap of PLAN.md section 8.2.
|
||||||
|
func (w *AcmeCertWatcher) HasCertificateFor(host string) bool {
|
||||||
|
loaded := w.certificates.Load()
|
||||||
|
if loaded == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
certificates := *loaded
|
||||||
|
if _, ok := certificates[host]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := certificates[wildcardOf(host)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package tlsprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeCertPEM issues a self-signed certificate for the given names.
|
||||||
|
func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: names[0]},
|
||||||
|
DNSNames: names,
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||||
|
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAcmeStore builds an acme.json in the shape Traefik writes: a map of
|
||||||
|
// resolver name to that resolver's account and certificates.
|
||||||
|
func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][]string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
type domain struct {
|
||||||
|
Main string `json:"main"`
|
||||||
|
SANs []string `json:"sans"`
|
||||||
|
}
|
||||||
|
type certificate struct {
|
||||||
|
Domain domain `json:"domain"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var certificates []certificate
|
||||||
|
for main, sans := range entries {
|
||||||
|
certPEM, keyPEM := makeCertPEM(t, append([]string{main}, sans...)...)
|
||||||
|
certificates = append(certificates, certificate{
|
||||||
|
Domain: domain{Main: main, SANs: sans},
|
||||||
|
Certificate: base64.StdEncoding.EncodeToString(certPEM),
|
||||||
|
Key: base64.StdEncoding.EncodeToString(keyPEM),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
store := map[string]any{
|
||||||
|
resolverName: map[string]any{
|
||||||
|
// An Account block is always present and must be ignored.
|
||||||
|
"Account": map[string]any{"Email": "ops@example.test"},
|
||||||
|
"certificates": certificates,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "acme.json")
|
||||||
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||||
|
t.Fatalf("write store: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcmeCertWatcher_ServesCertificateBySNI(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{
|
||||||
|
"mail.example.test": nil,
|
||||||
|
"webmail.example.test": nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
watcher, err := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAcmeCertWatcher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := watcher.GetCertificate(&tls.ClientHelloInfo{ServerName: "webmail.example.test"})
|
||||||
|
if err != nil || cert == nil {
|
||||||
|
t.Fatalf("GetCertificate: cert=%v err=%v", cert, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolver name varies per Coolify installation, so the parser must not
|
||||||
|
// depend on it (PLAN.md section 8.2, trap 3).
|
||||||
|
func TestAcmeCertWatcher_HandlesAnyResolverName(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "some-custom-resolver-name", map[string][]string{"mail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, err := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAcmeCertWatcher: %v", err)
|
||||||
|
}
|
||||||
|
if !watcher.HasCertificateFor("mail.example.test") {
|
||||||
|
t.Error("certificate was not found under a non-default resolver name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SAN entry must be served too, not just the main domain.
|
||||||
|
func TestAcmeCertWatcher_IndexesSubjectAlternativeNames(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{
|
||||||
|
"example.test": {"mta-sts.example.test", "autoconfig.example.test"},
|
||||||
|
})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "example.test")
|
||||||
|
|
||||||
|
for _, name := range []string{"example.test", "mta-sts.example.test", "autoconfig.example.test"} {
|
||||||
|
if !watcher.HasCertificateFor(name) {
|
||||||
|
t.Errorf("no certificate indexed for %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traefik replaces the file by rename. A watcher bound to the old inode would
|
||||||
|
// never see this; polling the path does (PLAN.md section 8.2, trap 1).
|
||||||
|
func TestAcmeCertWatcher_PicksUpAtomicRename(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, err := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAcmeCertWatcher: %v", err)
|
||||||
|
}
|
||||||
|
if watcher.HasCertificateFor("new.example.test") {
|
||||||
|
t.Fatal("the new host is present before the renewal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate Traefik: write a temporary file, then rename it over the old one.
|
||||||
|
staging := t.TempDir()
|
||||||
|
writeAcmeStore(t, staging, "letsencrypt", map[string][]string{
|
||||||
|
"mail.example.test": nil,
|
||||||
|
"new.example.test": nil,
|
||||||
|
})
|
||||||
|
if err := os.Rename(filepath.Join(staging, "acme.json"), filepath.Join(dir, "acme.json")); err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := watcher.reload(); err != nil {
|
||||||
|
t.Fatalf("reload after rename: %v", err)
|
||||||
|
}
|
||||||
|
if !watcher.HasCertificateFor("new.example.test") {
|
||||||
|
t.Error("the renewed store was not picked up after the atomic rename")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unreadable or truncated file must never replace a working store with an
|
||||||
|
// empty one (PLAN.md section 8.4).
|
||||||
|
func TestAcmeCertWatcher_KeepsPreviousStoreOnParseFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte("{ truncated"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write truncated file: %v", err)
|
||||||
|
}
|
||||||
|
if err := watcher.reload(); err == nil {
|
||||||
|
t.Fatal("expected reload to report the parse failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !watcher.HasCertificateFor("mail.example.test") {
|
||||||
|
t.Error("a truncated file wiped out the certificates already loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A store with no certificate for the mail host is a deployment error: no
|
||||||
|
// router was declared for it, so Traefik never requested one.
|
||||||
|
func TestAcmeCertWatcher_ReportsMissingMailHost(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"webmail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
|
||||||
|
if watcher.HasCertificateFor("mail.example.test") {
|
||||||
|
t.Error("HasCertificateFor must report the missing mail host so the preflight can fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown SNI falls back to the mail host certificate and is counted.
|
||||||
|
func TestAcmeCertWatcher_UnknownSniFallsBackAndCounts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
|
||||||
|
cert, err := watcher.GetCertificate(&tls.ClientHelloInfo{ServerName: "stranger.invalid"})
|
||||||
|
if err != nil || cert == nil {
|
||||||
|
t.Fatalf("expected the default certificate, got cert=%v err=%v", cert, err)
|
||||||
|
}
|
||||||
|
if watcher.UnknownSNICount() != 1 {
|
||||||
|
t.Errorf("UnknownSNICount = %d, want 1", watcher.UnknownSNICount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wildcard certificate must cover the hosts under it.
|
||||||
|
func TestAcmeCertWatcher_MatchesWildcard(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"*.example.test": nil})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "*.example.test")
|
||||||
|
|
||||||
|
if _, err := watcher.GetCertificate(&tls.ClientHelloInfo{ServerName: "mail.example.test"}); err != nil {
|
||||||
|
t.Errorf("wildcard certificate did not cover mail.example.test: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing file at startup is fatal on purpose: the operator must know
|
||||||
|
// immediately, not ninety days later.
|
||||||
|
func TestAcmeCertWatcher_FailsWhenStoreMissing(t *testing.T) {
|
||||||
|
if _, err := NewAcmeCertWatcher(t.TempDir(), "acme.json", "mail.example.test"); err == nil {
|
||||||
|
t.Fatal("expected construction to fail without an acme.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An identical file must not cause a pointless swap; LastReload only advances
|
||||||
|
// when the content actually changed.
|
||||||
|
func TestAcmeCertWatcher_SkipsReloadWhenContentUnchanged(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
|
||||||
|
|
||||||
|
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
|
||||||
|
first := watcher.LastReload()
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
if err := watcher.reload(); err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !watcher.LastReload().Equal(first) {
|
||||||
|
t.Error("an unchanged file triggered a reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package vault seals the reusable secrets the system stores in Postgres -
|
||||||
|
// DKIM private keys and Cloudflare tokens (PLAN.md section 9). These are
|
||||||
|
// encrypted, never hashed: they have to be recoverable to be used at all.
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurrentKeyVersion labels secrets sealed with the KEK derived below. Bumping
|
||||||
|
// it alongside a new derivation lets old rows stay readable during a rotation
|
||||||
|
// (PLAN.md section 9, *_key_version columns).
|
||||||
|
const CurrentKeyVersion = 1
|
||||||
|
|
||||||
|
// hkdfInfo binds the derived key to this purpose, so the same master key used
|
||||||
|
// elsewhere cannot produce the same KEK.
|
||||||
|
const hkdfInfo = "lambdamail/secret-vault/v1"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrMasterKeyMissing is returned when the process has no master key. It
|
||||||
|
// is fatal by design: continuing would mean writing secrets nobody can
|
||||||
|
// decrypt later, or silently storing them in the clear.
|
||||||
|
ErrMasterKeyMissing = errors.New("vault: LAMBDAMAIL_MASTER_KEY is not set")
|
||||||
|
|
||||||
|
ErrUnknownKeyVersion = errors.New("vault: secret was sealed with an unknown key version")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecretVault seals and opens secrets with AES-256-GCM under a key derived
|
||||||
|
// from the operator's master key.
|
||||||
|
type SecretVault struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// New derives the key-encryption key from masterKey. The master key is passed
|
||||||
|
// through HKDF rather than used directly so that a short or low-entropy
|
||||||
|
// operator value still yields a full-length AES-256 key.
|
||||||
|
func New(masterKey string) (*SecretVault, error) {
|
||||||
|
if masterKey == "" {
|
||||||
|
return nil, ErrMasterKeyMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
kek := make([]byte, 32)
|
||||||
|
// A fixed salt keeps derivation deterministic across restarts, which is
|
||||||
|
// required: a random salt would make every previously sealed secret
|
||||||
|
// unreadable after a restart.
|
||||||
|
salt := sha256.Sum256([]byte(hkdfInfo))
|
||||||
|
if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(masterKey), salt[:], []byte(hkdfInfo)), kek); err != nil {
|
||||||
|
return nil, fmt.Errorf("derive kek: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(kek)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create gcm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SecretVault{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal encrypts plaintext, returning the ciphertext and the nonce to store
|
||||||
|
// beside it. The nonce is stored separately because the schema models it as
|
||||||
|
// its own column (PLAN.md section 9).
|
||||||
|
func (v *SecretVault) Seal(plaintext []byte) (ciphertext, nonce []byte, err error) {
|
||||||
|
nonce = make([]byte, v.aead.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
return v.aead.Seal(nil, nonce, plaintext, nil), nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open reverses Seal. A wrong master key surfaces here as an authentication
|
||||||
|
// failure rather than as silently corrupt plaintext, because GCM is
|
||||||
|
// authenticated.
|
||||||
|
func (v *SecretVault) Open(ciphertext, nonce []byte, keyVersion int) ([]byte, error) {
|
||||||
|
if keyVersion != CurrentKeyVersion {
|
||||||
|
return nil, fmt.Errorf("%w: %d", ErrUnknownKeyVersion, keyVersion)
|
||||||
|
}
|
||||||
|
if len(nonce) != v.aead.NonceSize() {
|
||||||
|
return nil, fmt.Errorf("vault: nonce is %d bytes, want %d", len(nonce), v.aead.NonceSize())
|
||||||
|
}
|
||||||
|
plaintext, err := v.aead.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vault: cannot open secret (wrong master key or corrupt data): %w", err)
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecretVault_SealOpenRoundTrip(t *testing.T) {
|
||||||
|
v, err := New("master-key-for-tests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := []byte("-----BEGIN PRIVATE KEY-----\nnot-really\n-----END PRIVATE KEY-----")
|
||||||
|
ciphertext, nonce, err := v.Seal(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Seal: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(ciphertext, secret) {
|
||||||
|
t.Error("plaintext is recoverable from the ciphertext")
|
||||||
|
}
|
||||||
|
|
||||||
|
opened, err := v.Open(ciphertext, nonce, CurrentKeyVersion)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(opened, secret) {
|
||||||
|
t.Errorf("round trip mismatch: got %q, want %q", opened, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sealing the same value twice must not produce the same ciphertext, or an
|
||||||
|
// observer with database access could tell which domains share a key.
|
||||||
|
func TestSecretVault_SealIsNonDeterministic(t *testing.T) {
|
||||||
|
v, _ := New("master-key-for-tests")
|
||||||
|
|
||||||
|
first, _, _ := v.Seal([]byte("same"))
|
||||||
|
second, _, _ := v.Seal([]byte("same"))
|
||||||
|
|
||||||
|
if bytes.Equal(first, second) {
|
||||||
|
t.Error("sealing the same plaintext twice produced identical ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The KEK must survive a restart, otherwise every stored secret becomes
|
||||||
|
// unreadable the first time the process is recreated.
|
||||||
|
func TestSecretVault_DerivationIsStableAcrossInstances(t *testing.T) {
|
||||||
|
first, _ := New("master-key-for-tests")
|
||||||
|
ciphertext, nonce, _ := first.Seal([]byte("payload"))
|
||||||
|
|
||||||
|
second, _ := New("master-key-for-tests")
|
||||||
|
opened, err := second.Open(ciphertext, nonce, CurrentKeyVersion)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a freshly constructed vault could not open the secret: %v", err)
|
||||||
|
}
|
||||||
|
if string(opened) != "payload" {
|
||||||
|
t.Errorf("got %q, want %q", opened, "payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretVault_WrongMasterKeyFails(t *testing.T) {
|
||||||
|
good, _ := New("master-key-for-tests")
|
||||||
|
ciphertext, nonce, _ := good.Seal([]byte("payload"))
|
||||||
|
|
||||||
|
bad, _ := New("a-different-master-key")
|
||||||
|
if _, err := bad.Open(ciphertext, nonce, CurrentKeyVersion); err == nil {
|
||||||
|
t.Fatal("expected an authentication failure with the wrong master key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refusing to start without a master key beats writing secrets nobody can
|
||||||
|
// ever read back (PLAN.md risk R9).
|
||||||
|
func TestSecretVault_RequiresMasterKey(t *testing.T) {
|
||||||
|
if _, err := New(""); !errors.Is(err, ErrMasterKeyMissing) {
|
||||||
|
t.Fatalf("error = %v, want ErrMasterKeyMissing", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretVault_RejectsUnknownKeyVersion(t *testing.T) {
|
||||||
|
v, _ := New("master-key-for-tests")
|
||||||
|
ciphertext, nonce, _ := v.Seal([]byte("payload"))
|
||||||
|
|
||||||
|
if _, err := v.Open(ciphertext, nonce, 99); !errors.Is(err, ErrUnknownKeyVersion) {
|
||||||
|
t.Fatalf("error = %v, want ErrUnknownKeyVersion", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user