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:
@@ -1,10 +1,13 @@
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
// genericAuthFailure ist bewusst IMMER derselbe Text, unabhängig davon,
|
||||
@@ -38,6 +41,12 @@ func (s *Session) handlePass(ctx context.Context, cmd command) bool {
|
||||
if len(cmd.Args) != 1 {
|
||||
return writeErr(s.writer, "PASS requires a password") == nil
|
||||
}
|
||||
if s.tlsConfig != nil && !s.tlsActive {
|
||||
// Akzeptanzkriterium 1 (ING-06): Anmeldedaten werden
|
||||
// ausschließlich über TLS oder nach erfolgreichem STLS
|
||||
// akzeptiert, sobald der Server TLS überhaupt anbietet.
|
||||
return writeErr(s.writer, "TLS required before authentication, use STLS") == nil
|
||||
}
|
||||
if s.auth == nil {
|
||||
return writeErr(s.writer, genericAuthFailure) == nil
|
||||
}
|
||||
@@ -208,3 +217,37 @@ func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (in
|
||||
}
|
||||
return 0, fmt.Errorf("no such message")
|
||||
}
|
||||
|
||||
// handleStls implementiert STLS (RFC 2595 §4): TLS-Aufwertung der
|
||||
// bestehenden Klartextverbindung, nur vor der Anmeldung erlaubt.
|
||||
func (s *Session) handleStls(ctx context.Context) bool {
|
||||
if s.tlsConfig == nil {
|
||||
return writeErr(s.writer, "TLS not available") == nil
|
||||
}
|
||||
if s.tlsActive {
|
||||
return writeErr(s.writer, "already using TLS") == nil
|
||||
}
|
||||
if s.state != Authorization {
|
||||
return writeErr(s.writer, "command not valid in this state") == nil
|
||||
}
|
||||
if err := writeOK(s.writer, "begin TLS negotiation"); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tlsConn, err := tlscert.UpgradeServer(ctx, s.conn, s.tlsConfig)
|
||||
if err != nil {
|
||||
// Handshake fehlgeschlagen -> Verbindung beenden, kein Rückfall
|
||||
// auf Klartext.
|
||||
return false
|
||||
}
|
||||
s.conn = tlsConn
|
||||
// Reader/Writer NEU aufsetzen (nicht wiederverwenden): verhindert,
|
||||
// dass vor dem Handshake gepufferte Klartextdaten nach dem Wechsel
|
||||
// noch als Kommandos verarbeitet werden (RFC 2595 §4,
|
||||
// Command-Injection-Schutz).
|
||||
s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes)
|
||||
s.writer = bufio.NewWriter(tlsConn)
|
||||
s.tlsActive = true
|
||||
s.pendingUsername = ""
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package pop3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -11,12 +12,17 @@ import (
|
||||
|
||||
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
|
||||
// Goroutine (Akzeptanzkriterium 1) — gleiches Muster wie
|
||||
// mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht
|
||||
// dieser Kachel.
|
||||
// mail/internal/imap.Server. tlsConfig ist optional: nil bedeutet, STLS
|
||||
// ist nicht verfügbar und USER/PASS sind wie vor ING-06 uneingeschränkt
|
||||
// erlaubt (Klartextbetrieb, z. B. hinter einem separaten TLS-Terminator
|
||||
// oder in Tests); ist tlsConfig gesetzt, verlangt PASS zwingend STLS
|
||||
// oder eine bereits per implizitem TLS-Listener verschlüsselte
|
||||
// Verbindung (Akzeptanzkriterium 1).
|
||||
type Server struct {
|
||||
auth Authenticator
|
||||
store MailboxStore
|
||||
guardCfg protoguard.Config
|
||||
auth Authenticator
|
||||
store MailboxStore
|
||||
guardCfg protoguard.Config
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||
@@ -30,6 +36,13 @@ func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg p
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg}
|
||||
}
|
||||
|
||||
// NewServerWithGuardAndTLSConfig erlaubt zusätzlich, STLS/TLS zu
|
||||
// aktivieren (ING-06). tlsConfig sollte über tlscert.Store.Config()
|
||||
// erzeugt werden.
|
||||
func NewServerWithGuardAndTLSConfig(auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config) *Server {
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
go func() {
|
||||
@@ -49,7 +62,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
}
|
||||
return fmt.Errorf("pop3: verbindung annehmen: %w", err)
|
||||
}
|
||||
session := newSession(conn, srv.auth, srv.store, srv.guardCfg)
|
||||
session := newSession(conn, srv.auth, srv.store, srv.guardCfg, srv.tlsConfig)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pop3
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
@@ -34,22 +35,32 @@ type Session struct {
|
||||
store MailboxStore
|
||||
guard *protoguard.Guard
|
||||
|
||||
// tlsConfig ist nil, wenn dieser Server ohne TLS/STLS betrieben wird
|
||||
// (Akzeptanzkriterium 1 greift dann nicht — Rückwärtskompatibilität
|
||||
// zu ING-01/ING-02). tlsActive ist von Anfang an true, wenn die
|
||||
// Verbindung bereits über einen impliziten TLS-Listener hereinkam.
|
||||
tlsConfig *tls.Config
|
||||
tlsActive bool
|
||||
|
||||
state State
|
||||
pendingUsername string // nach USER, vor erfolgreichem PASS
|
||||
username string // nach erfolgreichem PASS
|
||||
deleted map[int]bool
|
||||
}
|
||||
|
||||
func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session {
|
||||
func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config) *Session {
|
||||
_, alreadyTLS := conn.(*tls.Conn)
|
||||
return &Session{
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
guard: protoguard.New(guardCfg),
|
||||
state: Authorization,
|
||||
deleted: map[int]bool{},
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
guard: protoguard.New(guardCfg),
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
state: Authorization,
|
||||
deleted: map[int]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +150,8 @@ func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
||||
return s.handleDele(cmd)
|
||||
case "QUIT":
|
||||
return s.handleQuit(ctx)
|
||||
case "STLS":
|
||||
return s.handleStls(ctx)
|
||||
default:
|
||||
return writeErr(s.writer, "unknown command") == nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
func startTLSTestServer(t *testing.T, tlsConfig *tls.Config) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
||||
store := newFakeMailboxStore()
|
||||
srv := NewServerWithGuardAndTLSConfig(auth, store, protoguard.DefaultConfig(), tlsConfig)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listener: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = srv.Serve(ctx, listener)
|
||||
close(done)
|
||||
}()
|
||||
return listener.Addr().String(), func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// TestPass_RequiresTLS ist die geforderte Pflichtprüfung 2 (ING-06):
|
||||
// Login-Versuch (hier: PASS) ohne TLS/STLS wird verweigert, sobald der
|
||||
// Server TLS anbietet (Akzeptanzkriterium 1).
|
||||
func TestPass_RequiresTLS(t *testing.T) {
|
||||
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("testzertifikat erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(cert)
|
||||
addr, stop := startTLSTestServer(t, store.Config())
|
||||
defer stop()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = conn.Write([]byte("USER alice\r\n"))
|
||||
_, _ = reader.ReadString('\n')
|
||||
_, _ = conn.Write([]byte("PASS geheim123\r\n"))
|
||||
resp, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
if resp[0] != '-' {
|
||||
t.Fatalf("erwartete -ERR (TLS erforderlich), habe: %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStls_UpgradesConnectionAndAllowsLogin belegt, dass PASS nach
|
||||
// erfolgreichem STLS akzeptiert wird — die Kehrseite von
|
||||
// TestPass_RequiresTLS.
|
||||
func TestStls_UpgradesConnectionAndAllowsLogin(t *testing.T) {
|
||||
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("testzertifikat erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(cert)
|
||||
addr, stop := startTLSTestServer(t, store.Config())
|
||||
defer stop()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = conn.Write([]byte("STLS\r\n"))
|
||||
resp, _ := reader.ReadString('\n')
|
||||
if resp[0] != '+' {
|
||||
t.Fatalf("erwartete +OK für STLS, habe: %q", resp)
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake: %v", err)
|
||||
}
|
||||
tlsReader := bufio.NewReader(tlsConn)
|
||||
|
||||
_, _ = tlsConn.Write([]byte("USER alice\r\n"))
|
||||
_, _ = tlsReader.ReadString('\n')
|
||||
_, _ = tlsConn.Write([]byte("PASS geheim123\r\n"))
|
||||
resp, err = tlsReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort über tls lesen: %v", err)
|
||||
}
|
||||
if resp[0] != '+' {
|
||||
t.Fatalf("erwartete +OK nach STLS, habe: %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLS_CertificateRotationWithoutDroppingExistingSessions ist die
|
||||
// geforderte Pflichtprüfung 3 (ING-06): Zertifikatsrotation im
|
||||
// laufenden Betrieb ohne Dienstunterbrechung — eine bereits etablierte
|
||||
// TLS-Session bleibt nach dem Wechsel nutzbar, eine NEUE Verbindung
|
||||
// bekommt das neue Zertifikat.
|
||||
func TestTLS_CertificateRotationWithoutDroppingExistingSessions(t *testing.T) {
|
||||
certA, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("zertifikat a erzeugen: %v", err)
|
||||
}
|
||||
certB, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("zertifikat b erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(certA)
|
||||
addr, stop := startTLSTestServer(t, store.Config())
|
||||
defer stop()
|
||||
|
||||
// Erste Verbindung, VOR der Rotation aufgebaut, wird offen gehalten.
|
||||
firstConn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial 1: %v", err)
|
||||
}
|
||||
defer func() { _ = firstConn.Close() }()
|
||||
r1 := bufio.NewReader(firstConn)
|
||||
_, _ = r1.ReadString('\n')
|
||||
_, _ = firstConn.Write([]byte("STLS\r\n"))
|
||||
_, _ = r1.ReadString('\n')
|
||||
firstTLS := tls.Client(firstConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := firstTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 1: %v", err)
|
||||
}
|
||||
firstPeerCert := firstTLS.ConnectionState().PeerCertificates[0].Raw
|
||||
if string(firstPeerCert) != string(certA.Certificate[0]) {
|
||||
t.Fatalf("erste verbindung hätte zertifikat a bekommen sollen")
|
||||
}
|
||||
|
||||
// Rotation im laufenden Betrieb.
|
||||
store.Replace(certB)
|
||||
|
||||
// Zweite, NEUE Verbindung bekommt das neue Zertifikat.
|
||||
secondConn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial 2: %v", err)
|
||||
}
|
||||
defer func() { _ = secondConn.Close() }()
|
||||
r2 := bufio.NewReader(secondConn)
|
||||
_, _ = r2.ReadString('\n')
|
||||
_, _ = secondConn.Write([]byte("STLS\r\n"))
|
||||
_, _ = r2.ReadString('\n')
|
||||
secondTLS := tls.Client(secondConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := secondTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 2: %v", err)
|
||||
}
|
||||
secondPeerCert := secondTLS.ConnectionState().PeerCertificates[0].Raw
|
||||
if string(secondPeerCert) != string(certB.Certificate[0]) {
|
||||
t.Fatalf("zweite verbindung hätte NACH der rotation zertifikat b bekommen sollen")
|
||||
}
|
||||
|
||||
// Erste, bereits etablierte Verbindung ist nach der Rotation weiter
|
||||
// UNGESTÖRT nutzbar (kein Verbindungsabriss).
|
||||
tlsReader1 := bufio.NewReader(firstTLS)
|
||||
_, _ = firstTLS.Write([]byte("USER alice\r\n"))
|
||||
_, _ = tlsReader1.ReadString('\n')
|
||||
_, _ = firstTLS.Write([]byte("PASS geheim123\r\n"))
|
||||
resp, err := tlsReader1.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("erste verbindung nach rotation unterbrochen: %v", err)
|
||||
}
|
||||
if resp[0] != '+' {
|
||||
t.Fatalf("erste verbindung nach rotation nicht mehr funktionsfähig: %q", resp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user