#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