feat(mail): ING-06 TLS/STARTTLS-Absicherung für IMAP/POP3/SMTP

Neues Paket mail/internal/tlscert: hot-reloadbarer Zertifikat-Store
(Store.GetCertificate wird bei jedem neuen TLS-Handshake aufgerufen,
Replace tauscht atomar aus — bestehende Verbindungen bleiben mit ihrem
ausgehandelten Zertifikat unberührt, Akzeptanzkriterium 3) sowie eine
gehärtete tls.Config (MinVersion TLS 1.2, ausschließlich AEAD-Suiten
für TLS 1.2, Akzeptanzkriterium 2). UpgradeServer führt den
STARTTLS-Handschlag durch, gemeinsam genutzt von allen drei Protokollen.

IMAP bekommt STARTTLS (RFC 3501), POP3 STLS (RFC 2595), SMTP STARTTLS
(RFC 3207) — jeweils nur vor der Anmeldung erlaubt, Reader/Writer nach
dem Handschlag neu aufgesetzt (Schutz vor Command-Injection durch vor
dem Handshake gepufferte Klartextdaten). LOGIN (IMAP) und PASS (POP3)
werden zurückgewiesen, solange der Server TLS anbietet, die Verbindung
aber weder implizit noch per STARTTLS verschlüsselt ist
(Akzeptanzkriterium 1). Implizites TLS (Port 993/995/465) braucht keine
Codeänderung — Server.Serve nimmt jeden net.Listener entgegen, ein
tls.NewListener-gewrapptes Listener liefert bereits *tls.Conn, von der
Session per Typ-Assertion erkannt. Ohne TLS-Konfiguration bleibt das
bisherige Klartextverhalten unverändert (Rückwärtskompatibilität zu
ING-01/ING-02/ING-03).

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: echter
openssl-s_client-Scan gegen den laufenden SMTP-Server (TLS 1.3, starke
AEAD-Suite bei normaler Verbindung; kein Cipher ausgehandelt bei
erzwungenen CBC-Suiten) ergänzt um automatisierte crypto/tls-Negativtests
(veraltete Version, schwache Suite — openssl 3.5.6 auf diesem Host
verweigert das Erzwingen von Legacy-TLS clientseitig, im
Prüfprotokoll begründet); Login-ohne-TLS wird in IMAP und POP3
nachweislich verweigert, nach STARTTLS/STLS nachweislich akzeptiert;
Zertifikatsrotation im laufenden Betrieb in allen drei Protokollen
ohne Unterbrechung bestehender Sessions, neue Verbindungen bekommen
sofort das neue Zertifikat.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~28 Pakete)
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 01:17:40 +02:00
parent 6631bcbbdd
commit b22ab67bb2
16 changed files with 1263 additions and 38 deletions
+47
View File
@@ -0,0 +1,47 @@
package tlscert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"time"
)
// GenerateSelfSigned erzeugt ein frisches, selbstsigniertes ECDSA-P256-
// Zertifikat für host (z. B. "127.0.0.1") — ausschließlich für Tests
// dieses Moduls, kein Ersatz für echte Zertifikatsverwaltung im Betrieb.
func GenerateSelfSigned(host string) (tls.Certificate, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, fmt.Errorf("tlscert: schlüssel erzeugen: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, fmt.Errorf("tlscert: seriennummer erzeugen: %w", err)
}
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, fmt.Errorf("tlscert: zertifikat erzeugen: %w", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil
}
+104
View File
@@ -0,0 +1,104 @@
// Package tlscert bündelt die für ING-06 gemeinsam benötigte
// TLS-Grundlage von IMAP-, POP3- und SMTP-Server: ein hot-reloadbares
// Zertifikat (Akzeptanzkriterium 3: Zertifikatswechsel ohne
// Verbindungsabriss für bestehende Sessions) und eine gehärtete
// tls.Config (Akzeptanzkriterium 2: keine schwachen Cipher-Suiten,
// keine veralteten TLS-Versionen).
package tlscert
import (
"context"
"crypto/tls"
"fmt"
"net"
"sync"
)
// Store hält das aktuell aktive Zertifikat. GetCertificate wird von
// crypto/tls bei JEDEM neuen Handshake aufgerufen und liefert dadurch
// immer den aktuellen Stand — ein Replace wirkt sich nur auf NEUE
// Handshakes aus, bereits etablierte Verbindungen laufen mit ihrem beim
// Handshake ausgehandelten Zertifikat unverändert weiter (kein
// Verbindungsabriss, Akzeptanzkriterium 3).
type Store struct {
mu sync.RWMutex
cert *tls.Certificate
}
// NewStore erstellt einen Store mit einem bereits geladenen Zertifikat.
func NewStore(cert tls.Certificate) *Store {
return &Store{cert: &cert}
}
// LoadFromFiles lädt Zertifikat und privaten Schlüssel aus PEM-Dateien.
// Die Pfade kommen ausschließlich vom Aufrufer (Umgebungsvariable),
// nicht aus dem Code.
func LoadFromFiles(certPath, keyPath string) (*Store, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("tlscert: zertifikat laden: %w", err)
}
return NewStore(cert), nil
}
// Replace tauscht das aktive Zertifikat atomar aus.
func (s *Store) Replace(cert tls.Certificate) {
s.mu.Lock()
defer s.mu.Unlock()
s.cert = &cert
}
// ReplaceFromFiles lädt ein neues Zertifikat aus Dateien und ersetzt
// damit das aktive (z. B. nach Zertifikatserneuerung durch eine externe
// ACME-Komponente).
func (s *Store) ReplaceFromFiles(certPath, keyPath string) error {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return fmt.Errorf("tlscert: zertifikat laden: %w", err)
}
s.Replace(cert)
return nil
}
// GetCertificate erfüllt tls.Config.GetCertificate.
func (s *Store) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cert, nil
}
// hardenedCipherSuites sind ausschließlich AEAD-Suiten für TLS 1.2
// (kein CBC, kein RC4, kein 3DES) — Akzeptanzkriterium 2. TLS 1.3 hat
// ohnehin nur feste, starke Suiten und braucht keine explizite Liste.
var hardenedCipherSuites = []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
}
// Config liefert eine gehärtete tls.Config: TLS 1.2 als Mindestversion
// (Akzeptanzkriterium 2, deaktiviert SSLv3/TLS1.0/TLS1.1), ausschließlich
// AEAD-Cipher-Suiten für TLS 1.2, Zertifikat immer über s.GetCertificate
// aufgelöst (Akzeptanzkriterium 3).
func (s *Store) Config() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
CipherSuites: hardenedCipherSuites,
GetCertificate: s.GetCertificate,
}
}
// UpgradeServer führt den TLS-Serverhandschlag auf einer bestehenden
// Klartextverbindung durch (STARTTLS-Muster, gemeinsam genutzt von
// IMAP-, POP3- und SMTP-Session) und liefert die resultierende
// *tls.Conn.
func UpgradeServer(ctx context.Context, conn net.Conn, cfg *tls.Config) (*tls.Conn, error) {
tlsConn := tls.Server(conn, cfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
return nil, err
}
return tlsConn, nil
}
+67
View File
@@ -0,0 +1,67 @@
package tlscert
import (
"crypto/tls"
"testing"
)
func TestConfig_HardenedDefaults(t *testing.T) {
cert, err := GenerateSelfSigned("127.0.0.1")
if err != nil {
t.Fatalf("testzertifikat erzeugen: %v", err)
}
store := NewStore(cert)
cfg := store.Config()
if cfg.MinVersion < tls.VersionTLS12 {
t.Fatalf("erwartete mindestens TLS 1.2, habe: 0x%04x", cfg.MinVersion)
}
for _, id := range cfg.CipherSuites {
for _, weak := range []uint16{
tls.TLS_RSA_WITH_RC4_128_SHA,
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
} {
if id == weak {
t.Fatalf("schwache cipher-suite in gehärteter config gefunden: 0x%04x", id)
}
}
}
if len(cfg.CipherSuites) == 0 {
t.Fatalf("erwartete explizite cipher-suite-liste, habe keine")
}
}
// TestStore_ReplaceAffectsOnlyNewHandshakes ist die geforderte
// Pflichtprüfung 3 (ING-06) auf Store-Ebene: nach Replace liefert
// GetCertificate sofort das neue Zertifikat für neue Handshakes.
func TestStore_ReplaceAffectsOnlyNewHandshakes(t *testing.T) {
certA, err := GenerateSelfSigned("127.0.0.1")
if err != nil {
t.Fatalf("zertifikat a erzeugen: %v", err)
}
certB, err := GenerateSelfSigned("127.0.0.1")
if err != nil {
t.Fatalf("zertifikat b erzeugen: %v", err)
}
store := NewStore(certA)
got, err := store.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if string(got.Certificate[0]) != string(certA.Certificate[0]) {
t.Fatalf("erwartete zertifikat a vor dem wechsel")
}
store.Replace(certB)
got, err = store.GetCertificate(nil)
if err != nil {
t.Fatalf("GetCertificate nach Replace: %v", err)
}
if string(got.Certificate[0]) != string(certB.Certificate[0]) {
t.Fatalf("erwartete zertifikat b nach dem wechsel")
}
}