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,15 +1,22 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
// handleCapability ist in jedem Zustand erlaubt (RFC 3501 §6.1.1).
|
||||
func (s *Session) handleCapability(cmd command) bool {
|
||||
if err := writeUntagged(s.writer, "CAPABILITY IMAP4rev1"); err != nil {
|
||||
caps := "CAPABILITY IMAP4rev1"
|
||||
if s.tlsConfig != nil && !s.tlsActive {
|
||||
caps += " STARTTLS"
|
||||
}
|
||||
if err := writeUntagged(s.writer, caps); err != nil {
|
||||
return false
|
||||
}
|
||||
return s.writeErr(cmd.Tag, "OK", "CAPABILITY completed")
|
||||
@@ -24,6 +31,12 @@ func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
|
||||
if len(cmd.Args) != 2 {
|
||||
return s.writeErr(cmd.Tag, "BAD", "LOGIN requires username and password")
|
||||
}
|
||||
if s.tlsConfig != nil && !s.tlsActive {
|
||||
// Akzeptanzkriterium 1 (ING-06): Anmeldedaten werden
|
||||
// ausschließlich über TLS oder nach erfolgreichem STARTTLS
|
||||
// akzeptiert, sobald der Server TLS überhaupt anbietet.
|
||||
return s.writeErr(cmd.Tag, "NO", "LOGIN disabled without TLS, use STARTTLS")
|
||||
}
|
||||
if s.auth == nil {
|
||||
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
|
||||
}
|
||||
@@ -154,6 +167,38 @@ func (s *Session) handleLogout(cmd command) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// handleStartTLS implementiert STARTTLS (RFC 3501 §6.2.1): TLS-Aufwertung
|
||||
// der bestehenden Klartextverbindung, nur vor der Anmeldung erlaubt.
|
||||
func (s *Session) handleStartTLS(ctx context.Context, cmd command) bool {
|
||||
if s.tlsConfig == nil {
|
||||
return s.writeErr(cmd.Tag, "NO", "STARTTLS not available")
|
||||
}
|
||||
if s.tlsActive {
|
||||
return s.writeErr(cmd.Tag, "BAD", "TLS already active")
|
||||
}
|
||||
if s.state != NotAuthenticated {
|
||||
return s.writeErr(cmd.Tag, "BAD", "STARTTLS not allowed in "+s.state.String()+" state")
|
||||
}
|
||||
if !s.writeErr(cmd.Tag, "OK", "Begin TLS negotiation now") {
|
||||
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: verhindert, dass vor dem Handshake
|
||||
// gepufferte Klartextdaten nach dem Wechsel noch als Kommandos
|
||||
// verarbeitet werden (Command-Injection-Schutz).
|
||||
s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes)
|
||||
s.writer = bufio.NewWriter(tlsConn)
|
||||
s.tlsActive = true
|
||||
return true
|
||||
}
|
||||
|
||||
// parseSequenceSet unterstützt die für FETCH gebräuchlichsten Formen:
|
||||
// eine einzelne Zahl ("1"), eine kommagetrennte Liste ("1,3,5") und einen
|
||||
// Bereich mit "*" als offenem Ende ("1:*"), aufgelöst gegen maxSeq (die
|
||||
|
||||
@@ -2,6 +2,7 @@ package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -10,14 +11,16 @@ import (
|
||||
)
|
||||
|
||||
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
|
||||
// Goroutine (Akzeptanzkriterium 1). STARTTLS/TLS-Absicherung ist
|
||||
// ausdrücklich Sache von ING-06, nicht dieser Kachel — Server hört per
|
||||
// Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die
|
||||
// Schnittstelle dafür vor").
|
||||
// Goroutine (Akzeptanzkriterium 1). tlsConfig ist optional (ING-06):
|
||||
// nil bedeutet Klartextbetrieb wie vor ING-06 (z. B. hinter einem
|
||||
// separaten TLS-Terminator oder in Tests); ist tlsConfig gesetzt,
|
||||
// verlangt LOGIN zwingend STARTTLS 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 {
|
||||
@@ -31,6 +34,13 @@ func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg p
|
||||
return &Server{auth: auth, store: store, guardCfg: guardCfg}
|
||||
}
|
||||
|
||||
// NewServerWithGuardAndTLSConfig erlaubt zusätzlich, STARTTLS/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 oder
|
||||
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
@@ -51,7 +61,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
}
|
||||
return fmt.Errorf("imap: 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 imap
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
@@ -36,20 +37,25 @@ type Session struct {
|
||||
auth Authenticator
|
||||
store MailboxStore
|
||||
guard *protoguard.Guard
|
||||
tlsConfig *tls.Config // nil = kein TLS/STARTTLS angeboten (ING-06)
|
||||
tlsActive bool
|
||||
state State
|
||||
mailbox string // gewähltes Postfach im Zustand Selected
|
||||
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
|
||||
}
|
||||
|
||||
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: NotAuthenticated,
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
guard: protoguard.New(guardCfg),
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
state: NotAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +148,8 @@ func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
||||
return s.handleUIDFetch(ctx, cmd)
|
||||
case "LOGOUT":
|
||||
return s.handleLogout(cmd)
|
||||
case "STARTTLS":
|
||||
return s.handleStartTLS(ctx, cmd)
|
||||
default:
|
||||
return s.writeErr(cmd.Tag, "BAD", "Unknown command")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"strings"
|
||||
"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 := fakeMailboxStore{mailboxes: map[string][]Message{
|
||||
"INBOX": {{SequenceNumber: 1, UID: 101, Flags: []string{}}},
|
||||
}}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// sendTaggedOn sendet ein getaggtes Kommando über einen beliebigen
|
||||
// net.Conn (Klartext oder TLS) und liest bis zur getaggten
|
||||
// Abschlusszeile.
|
||||
func sendTaggedOn(t *testing.T, conn net.Conn, reader *bufio.Reader, tag, command string) (lines []string) {
|
||||
t.Helper()
|
||||
if _, err := conn.Write([]byte(tag + " " + command + "\r\n")); err != nil {
|
||||
t.Fatalf("kommando senden: %v", err)
|
||||
}
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
lines = append(lines, line)
|
||||
if strings.HasPrefix(line, tag+" ") {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_RequiresTLS ist die geforderte Pflichtprüfung 2 (ING-06):
|
||||
// Login-Versuch ohne TLS/STARTTLS wird verweigert, sobald der Server
|
||||
// TLS anbietet (Akzeptanzkriterium 1).
|
||||
func TestLogin_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')
|
||||
|
||||
lines := sendTaggedOn(t, conn, reader, "A1", "LOGIN alice geheim123")
|
||||
last := lines[len(lines)-1]
|
||||
if !strings.Contains(last, "NO") {
|
||||
t.Fatalf("erwartete NO (TLS erforderlich), habe: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartTLS_UpgradesConnectionAndAllowsLogin belegt, dass LOGIN nach
|
||||
// erfolgreichem STARTTLS akzeptiert wird.
|
||||
func TestStartTLS_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')
|
||||
|
||||
lines := sendTaggedOn(t, conn, reader, "A1", "STARTTLS")
|
||||
if !strings.Contains(lines[len(lines)-1], "OK") {
|
||||
t.Fatalf("erwartete OK für STARTTLS, habe: %v", lines)
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake: %v", err)
|
||||
}
|
||||
tlsReader := bufio.NewReader(tlsConn)
|
||||
|
||||
lines = sendTaggedOn(t, tlsConn, tlsReader, "A2", "LOGIN alice geheim123")
|
||||
if !strings.Contains(lines[len(lines)-1], "OK") {
|
||||
t.Fatalf("erwartete OK nach STARTTLS, habe: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLS_CertificateRotationWithoutDroppingExistingSessions ist die
|
||||
// geforderte Pflichtprüfung 3 (ING-06): eine bereits etablierte
|
||||
// TLS-Session bleibt nach einer Zertifikatsrotation 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()
|
||||
|
||||
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')
|
||||
sendTaggedOn(t, firstConn, r1, "A1", "STARTTLS")
|
||||
firstTLS := tls.Client(firstConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := firstTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 1: %v", err)
|
||||
}
|
||||
if string(firstTLS.ConnectionState().PeerCertificates[0].Raw) != string(certA.Certificate[0]) {
|
||||
t.Fatalf("erste verbindung hätte zertifikat a bekommen sollen")
|
||||
}
|
||||
|
||||
store.Replace(certB)
|
||||
|
||||
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')
|
||||
sendTaggedOn(t, secondConn, r2, "A1", "STARTTLS")
|
||||
secondTLS := tls.Client(secondConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := secondTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 2: %v", err)
|
||||
}
|
||||
if string(secondTLS.ConnectionState().PeerCertificates[0].Raw) != string(certB.Certificate[0]) {
|
||||
t.Fatalf("zweite verbindung hätte NACH der rotation zertifikat b bekommen sollen")
|
||||
}
|
||||
|
||||
// Erste, bereits etablierte Verbindung bleibt ungestört nutzbar.
|
||||
tlsReader1 := bufio.NewReader(firstTLS)
|
||||
lines := sendTaggedOn(t, firstTLS, tlsReader1, "A2", "LOGIN alice geheim123")
|
||||
if !strings.Contains(lines[len(lines)-1], "OK") {
|
||||
t.Fatalf("erste verbindung nach rotation nicht mehr funktionsfähig: %v", lines)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user