first commit
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gomail/internal/acme"
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// renewalMargin is how far before expiry a certificate is renewed.
|
||||
const renewalMargin = 30 * 24 * time.Hour
|
||||
|
||||
// ACMEManager obtains and caches ACME certificates per domain, encrypted at
|
||||
// rest (same HKDF-per-record scheme as everything else), and serves them
|
||||
// via a SNI-aware tls.Config.GetCertificate callback so a single listener
|
||||
// can present the right certificate for whichever domain a client connects
|
||||
// to. A background loop renews any certificate within renewalMargin of
|
||||
// expiry.
|
||||
type ACMEManager struct {
|
||||
database *db.DB
|
||||
mk *crypto.MasterKey
|
||||
directoryURL string
|
||||
contactEmail string
|
||||
responder *acme.ChallengeResponder
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
func NewACMEManager(database *db.DB, mk *crypto.MasterKey, directoryURL, contactEmail string, responder *acme.ChallengeResponder) *ACMEManager {
|
||||
return &ACMEManager{
|
||||
database: database, mk: mk, directoryURL: directoryURL, contactEmail: contactEmail,
|
||||
responder: responder, cache: make(map[string]*tls.Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
// TLSConfig returns a tls.Config whose GetCertificate looks up the right
|
||||
// cert per SNI, obtaining one on first use if none is cached yet.
|
||||
func (m *ACMEManager) TLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return m.CertificateFor(hello.ServerName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertificateFor returns a cached certificate for domain, obtaining one via
|
||||
// ACME (and caching it, in memory and encrypted in the DB) if not already
|
||||
// cached or if the cached one is expired/near expiry.
|
||||
func (m *ACMEManager) CertificateFor(domain string) (*tls.Certificate, error) {
|
||||
m.mu.RLock()
|
||||
cached, ok := m.cache[domain]
|
||||
m.mu.RUnlock()
|
||||
if ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if stored, err := m.loadFromDB(domain); err == nil {
|
||||
m.mu.Lock()
|
||||
m.cache[domain] = stored
|
||||
m.mu.Unlock()
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
cert, err := m.obtainAndStore(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func (m *ACMEManager) loadFromDB(domain string) (*tls.Certificate, error) {
|
||||
row, err := m.database.GetTLSCert(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.CertPEMEnc == nil || row.KeyPEMEnc == nil {
|
||||
return nil, fmt.Errorf("no cert material stored for %s", domain)
|
||||
}
|
||||
if row.ExpiresAt != nil && time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin)) {
|
||||
return nil, fmt.Errorf("stored cert for %s is expired or near expiry", domain)
|
||||
}
|
||||
|
||||
certPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-cert", row.CertPEMEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypting cert: %w", err)
|
||||
}
|
||||
keyPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-key", row.KeyPEMEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypting key: %w", err)
|
||||
}
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing stored cert/key: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func (m *ACMEManager) obtainAndStore(domain string) (*tls.Certificate, error) {
|
||||
accountKey, err := m.loadOrCreateAccountKey(domain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account key: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("obtaining ACME certificate", "domain", domain, "directory", m.directoryURL)
|
||||
certPEM, keyPEM, err := acme.Obtain(m.directoryURL, m.contactEmail, []string{domain}, accountKey, m.responder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ACME obtain for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing obtained cert/key: %w", err)
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if len(cert.Certificate) > 0 {
|
||||
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
|
||||
expiresAt = &leaf.NotAfter
|
||||
}
|
||||
}
|
||||
|
||||
existing, _ := m.database.GetTLSCert(domain)
|
||||
recordID := domain
|
||||
if existing != nil {
|
||||
recordID = existing.ID
|
||||
}
|
||||
encCert, err := crypto.Encrypt(m.mk, recordID, "tls-cert", certPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypting cert: %w", err)
|
||||
}
|
||||
encKey, err := crypto.Encrypt(m.mk, recordID, "tls-key", keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypting key: %w", err)
|
||||
}
|
||||
|
||||
if err := m.database.UpsertTLSCert(&db.TLSCert{
|
||||
ID: recordID, Domain: domain, CertPEMEnc: encCert, KeyPEMEnc: encKey, ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("storing cert: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.cache[domain] = &cert
|
||||
m.mu.Unlock()
|
||||
|
||||
slog.Info("ACME certificate obtained and stored", "domain", domain, "expires_at", expiresAt)
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func (m *ACMEManager) loadOrCreateAccountKey(domain string) (*acme.AccountKey, error) {
|
||||
row, err := m.database.GetTLSCert(domain)
|
||||
if err == nil && row.ACMEAccountKeyEnc != nil {
|
||||
plain, decErr := crypto.Decrypt(m.mk, row.ID, "acme-account-key", row.ACMEAccountKeyEnc)
|
||||
if decErr == nil {
|
||||
if key, parseErr := acme.ParseAccountKeyPEM(plain); parseErr == nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key, err := acme.GenerateAccountKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := key.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recordID := domain
|
||||
if row != nil {
|
||||
recordID = row.ID
|
||||
}
|
||||
encKey, err := crypto.Encrypt(m.mk, recordID, "acme-account-key", keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.database.SetACMEAccountKey(domain, encKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// StartRenewalLoop runs a background check (default: daily) and renews any
|
||||
// domain whose cached/stored certificate is within renewalMargin of expiry.
|
||||
// domains is the full set this instance is responsible for — typically all
|
||||
// active hosted domains plus the server's own hostname.
|
||||
func (m *ACMEManager) StartRenewalLoop(ctx context.Context, domains []string, checkInterval time.Duration) {
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
checkAndRenew := func() {
|
||||
for _, domain := range domains {
|
||||
row, err := m.database.GetTLSCert(domain)
|
||||
needsRenewal := err != nil || row.ExpiresAt == nil || time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin))
|
||||
if !needsRenewal {
|
||||
continue
|
||||
}
|
||||
slog.Info("renewing ACME certificate", "domain", domain)
|
||||
m.mu.Lock()
|
||||
delete(m.cache, domain) // force re-obtain, not a stale in-memory hit
|
||||
m.mu.Unlock()
|
||||
if _, err := m.obtainAndStore(domain); err != nil {
|
||||
slog.Error("ACME renewal failed", "domain", domain, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
checkAndRenew()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package tlsutil provides certificate loading: LoadOrGenerate for the
|
||||
// file/self-signed paths (this file), and ACMEManager (acme_manager.go) for
|
||||
// real Let's Encrypt-style issuance via internal/acme. The self-signed
|
||||
// generator here remains the fallback for tls.mode "off" or "file" without
|
||||
// a cert on disk yet — genuinely necessary for local dev/testing, not a
|
||||
// placeholder for a missing feature.
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadOrGenerate returns a tls.Config for the given mode:
|
||||
// - "file": load cert/key from disk paths
|
||||
// - anything else ("acme" not yet implemented, "off"): generate a self-signed
|
||||
// cert so STARTTLS/IMAPS/etc. still work during development. Logs a loud
|
||||
// warning since this is never appropriate for production.
|
||||
func LoadOrGenerate(mode, hostname, certFile, keyFile string, minVersion uint16) (*tls.Config, error) {
|
||||
var cert tls.Certificate
|
||||
var err error
|
||||
|
||||
switch mode {
|
||||
case "file":
|
||||
cert, err = tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading TLS cert/key: %w", err)
|
||||
}
|
||||
case "acme":
|
||||
// Reaching here (rather than the real ACMEManager path in main.go)
|
||||
// means mode=="acme" but no acme_domains were configured — a real
|
||||
// ACME client exists (internal/acme, wired in main.go), it's just
|
||||
// not usable without knowing which domain(s) to request a cert for.
|
||||
slog.Warn("TLS mode is 'acme' but no acme_domains are configured — "+
|
||||
"generating a SELF-SIGNED certificate instead. Set tls.acme_domains "+
|
||||
"in config.yaml to enable real Let's Encrypt issuance.",
|
||||
"hostname", hostname)
|
||||
cert, err = generateSelfSigned(hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating self-signed cert: %w", err)
|
||||
}
|
||||
default:
|
||||
slog.Warn("TLS mode is 'off' — generating a SELF-SIGNED certificate. "+
|
||||
"This is fine for local testing but MUST NOT be used in production; "+
|
||||
"set tls.mode to 'acme' (with acme_domains configured) or 'file'.",
|
||||
"mode", mode, "hostname", hostname)
|
||||
cert, err = generateSelfSigned(hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating self-signed cert: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: minVersion,
|
||||
ServerName: hostname,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseMinVersion converts the config string ("TLS12"/"TLS13") to the
|
||||
// crypto/tls constant.
|
||||
func ParseMinVersion(s string) uint16 {
|
||||
if s == "TLS13" {
|
||||
return tls.VersionTLS13
|
||||
}
|
||||
return tls.VersionTLS12
|
||||
}
|
||||
|
||||
func generateSelfSigned(hostname string) (tls.Certificate, error) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: hostname, Organization: []string{"GoMail (self-signed, dev only)"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
template.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
template.DNSNames = []string{hostname}
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{derBytes},
|
||||
PrivateKey: priv,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user