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.
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package imap
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
|
)
|
|
|
|
// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser
|
|
// Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase
|
|
// konfigurierbar). Authenticated und Selected teilen sich denselben
|
|
// Timeout — beides ist bereits angemeldeter Zustand, nur die
|
|
// Anmeldephase braucht separate (typischerweise kürzere) Werte.
|
|
const (
|
|
phaseNotAuthenticated protoguard.Phase = "not_authenticated"
|
|
phaseSelected protoguard.Phase = "selected"
|
|
)
|
|
|
|
// maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive
|
|
// Fehlerbehandlung bei nicht-konformen Gegenstellen statt optimistischem
|
|
// Parsing, siehe Ticket-Vorbild Dovecot) — verhindert unbegrenztes
|
|
// Pufferwachstum durch eine Gegenstelle, die niemals CRLF sendet.
|
|
const maxCommandLineBytes = 8192
|
|
|
|
// Session ist eine einzelne IMAP-Verbindung mit eigener
|
|
// Zustandsmaschine (Akzeptanzkriterium 1).
|
|
type Session struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
writer *bufio.Writer
|
|
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, 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),
|
|
tlsConfig: tlsConfig,
|
|
tlsActive: alreadyTLS,
|
|
state: NotAuthenticated,
|
|
}
|
|
}
|
|
|
|
// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands.
|
|
func (s *Session) currentPhase() protoguard.Phase {
|
|
if s.state == NotAuthenticated {
|
|
return phaseNotAuthenticated
|
|
}
|
|
return phaseSelected
|
|
}
|
|
|
|
// State liefert den aktuellen Sitzungszustand (für Tests).
|
|
func (s *Session) State() State { return s.state }
|
|
|
|
// Serve führt die Sitzung bis LOGOUT oder Verbindungsende aus.
|
|
func (s *Session) Serve(ctx context.Context) {
|
|
defer func() { _ = s.conn.Close() }()
|
|
|
|
if err := writeUntagged(s.writer, "OK IMAP4rev1 Service Ready"); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
// Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase,
|
|
// vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die
|
|
// Phase (und damit den geltenden Timeout) ändern kann.
|
|
if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil {
|
|
return
|
|
}
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
// Verbindungsende (Timeout, Netzwerkabbruch oder harter
|
|
// Abbruch) — Session-Ressourcen werden über das defer
|
|
// conn.Close() oben zuverlässig freigegeben
|
|
// (Akzeptanzkriterium 1).
|
|
return
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
cmd, parseErr := parseCommandLine(line)
|
|
if parseErr != nil {
|
|
// Akzeptanzkriterium 3: ungültige Kommandosequenz -> Fehlercode,
|
|
// KEIN Verbindungsabbruch.
|
|
if err := writeUntagged(s.writer, "BAD Error in IMAP command received by server."); err != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !s.dispatch(ctx, cmd) {
|
|
return // LOGOUT oder nicht behebbarer Schreibfehler
|
|
}
|
|
}
|
|
}
|
|
|
|
// readLine liest eine CRLF- (oder LF-)terminierte Zeile ohne
|
|
// Zeilenumbruch. Überlange Zeilen (siehe maxCommandLineBytes) werden als
|
|
// Fehler behandelt statt unbegrenzt zu puffern.
|
|
func (s *Session) readLine() (string, error) {
|
|
line, err := s.reader.ReadString('\n')
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && line != "" {
|
|
// Letzte Zeile ohne abschließendes LF — als vollständige Zeile
|
|
// behandeln, danach ohnehin Verbindungsende.
|
|
return strings.TrimRight(line, "\r"), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// dispatch verarbeitet EIN geparstes Kommando. Rückgabewert false
|
|
// bedeutet: Sitzung beenden (LOGOUT abgeschlossen oder Schreibfehler).
|
|
func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
|
switch cmd.Name {
|
|
case "CAPABILITY":
|
|
return s.handleCapability(cmd)
|
|
case "LOGIN":
|
|
return s.handleLogin(ctx, cmd)
|
|
case "SELECT":
|
|
return s.handleSelect(ctx, cmd)
|
|
case "FETCH":
|
|
return s.handleFetch(ctx, cmd)
|
|
case "UID":
|
|
if len(cmd.Args) < 1 || strings.ToUpper(cmd.Args[0]) != "FETCH" {
|
|
return s.writeErr(cmd.Tag, "BAD", "Unsupported UID subcommand")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func (s *Session) writeErr(tag, status, text string) bool {
|
|
return writeTagged(s.writer, tag, status, text) == nil
|
|
}
|