diff --git a/migrations/0005_create_acme_tables.down.sql b/migrations/0005_create_acme_tables.down.sql new file mode 100644 index 0000000..e3c774e --- /dev/null +++ b/migrations/0005_create_acme_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS acme_pending_keys; +DROP TABLE IF EXISTS acme_certificates; +DROP TABLE IF EXISTS acme_accounts; diff --git a/migrations/0005_create_acme_tables.up.sql b/migrations/0005_create_acme_tables.up.sql new file mode 100644 index 0000000..d78fd45 --- /dev/null +++ b/migrations/0005_create_acme_tables.up.sql @@ -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() +); diff --git a/services/protocols/internal/application/port/certificate_store.go b/services/protocols/internal/application/port/certificate_store.go new file mode 100644 index 0000000..a0d7abd --- /dev/null +++ b/services/protocols/internal/application/port/certificate_store.go @@ -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 +} diff --git a/services/protocols/internal/domain/entity/tls_policy_enforcement_test.go b/services/protocols/internal/domain/entity/tls_policy_enforcement_test.go new file mode 100644 index 0000000..f0371ec --- /dev/null +++ b/services/protocols/internal/domain/entity/tls_policy_enforcement_test.go @@ -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") + } +} diff --git a/services/protocols/internal/infrastructure/acme/issuer.go b/services/protocols/internal/infrastructure/acme/issuer.go new file mode 100644 index 0000000..c9b45d7 --- /dev/null +++ b/services/protocols/internal/infrastructure/acme/issuer.go @@ -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 +} diff --git a/services/protocols/internal/infrastructure/acme/manager.go b/services/protocols/internal/infrastructure/acme/manager.go new file mode 100644 index 0000000..355cbd6 --- /dev/null +++ b/services/protocols/internal/infrastructure/acme/manager.go @@ -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) diff --git a/services/protocols/internal/infrastructure/postgres/acme_repository.go b/services/protocols/internal/infrastructure/postgres/acme_repository.go new file mode 100644 index 0000000..8830960 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/acme_repository.go @@ -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) diff --git a/services/protocols/internal/infrastructure/tls/acme_cert_watcher.go b/services/protocols/internal/infrastructure/tls/acme_cert_watcher.go new file mode 100644 index 0000000..995dad4 --- /dev/null +++ b/services/protocols/internal/infrastructure/tls/acme_cert_watcher.go @@ -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 +} diff --git a/services/protocols/internal/infrastructure/tls/acme_cert_watcher_test.go b/services/protocols/internal/infrastructure/tls/acme_cert_watcher_test.go new file mode 100644 index 0000000..102e58f --- /dev/null +++ b/services/protocols/internal/infrastructure/tls/acme_cert_watcher_test.go @@ -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") + } +} diff --git a/services/protocols/internal/infrastructure/vault/secret_vault.go b/services/protocols/internal/infrastructure/vault/secret_vault.go new file mode 100644 index 0000000..41152e6 --- /dev/null +++ b/services/protocols/internal/infrastructure/vault/secret_vault.go @@ -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 +} diff --git a/services/protocols/internal/infrastructure/vault/secret_vault_test.go b/services/protocols/internal/infrastructure/vault/secret_vault_test.go new file mode 100644 index 0000000..9d1ef90 --- /dev/null +++ b/services/protocols/internal/infrastructure/vault/secret_vault_test.go @@ -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) + } +}