Files
nexarch/mail/internal/smtp/session.go
T
sysops b22ab67bb2 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.
2026-09-01 01:17:40 +02:00

130 lines
3.0 KiB
Go

package smtp
import (
"bufio"
"context"
"crypto/tls"
"errors"
"io"
"net"
"strings"
)
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt
// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und
// mail/internal/pop3).
const maxCommandLineBytes = 8192
// Session ist eine einzelne SMTP-Verbindung mit eigener
// Zustandsmaschine (Akzeptanzkriterium 1).
type Session struct {
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
sink MessageSink
maxMessageBytes int64
tlsConfig *tls.Config // nil = kein STARTTLS angeboten (ING-06)
tlsActive bool
state State
from string
to []string
}
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *Session {
_, alreadyTLS := conn.(*tls.Conn)
return &Session{
conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn),
sink: sink,
maxMessageBytes: maxMessageBytes,
tlsConfig: tlsConfig,
tlsActive: alreadyTLS,
state: Greeting,
}
}
// State liefert den aktuellen Sitzungszustand (für Tests).
func (s *Session) State() State { return s.state }
// Serve führt die Sitzung bis QUIT oder Verbindungsende aus.
func (s *Session) Serve(ctx context.Context) {
defer func() { _ = s.conn.Close() }()
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
return
}
for {
line, err := s.readLine()
if err != nil {
return
}
if line == "" {
continue
}
verb, arg := parseCommand(line)
if !s.dispatch(ctx, verb, arg) {
return
}
}
}
func (s *Session) readLine() (string, error) {
line, err := s.reader.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && line != "" {
return strings.TrimRight(line, "\r"), nil
}
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung
// beenden (QUIT abgeschlossen oder nicht behebbarer Schreibfehler).
func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
switch verb {
case "HELO", "EHLO":
return s.handleHelo(verb, arg)
case "MAIL":
return s.handleMailFrom(arg)
case "RCPT":
return s.handleRcptTo(arg)
case "DATA":
return s.handleData(ctx)
case "RSET":
return s.handleRset()
case "STARTTLS":
return s.handleStartTLS(ctx)
case "NOOP":
return s.reply(250, "OK") == nil
case "QUIT":
_ = s.reply(221, "Bye")
return false
default:
return s.reply(500, "Command not recognized") == nil
}
}
// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur
// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen
// Größenüberschreitung abgelehnten Nachricht synchron, ohne den
// verworfenen Rest unbegrenzt im Speicher zu halten.
func (s *Session) drainUntilDot() error {
for {
line, err := s.readLine()
if err != nil {
return err
}
if line == "." {
return nil
}
}
}