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.
168 lines
4.8 KiB
Go
168 lines
4.8 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
|
)
|
|
|
|
func (s *Session) handleHelo(verb, arg string) bool {
|
|
if strings.TrimSpace(arg) == "" {
|
|
return s.reply(501, verb+" requires a domain/address") == nil
|
|
}
|
|
// HELO/EHLO setzt den Envelope zurück, falls bereits einer im
|
|
// Aufbau war (RFC 5321 §4.1.1.1).
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
|
|
if verb == "EHLO" {
|
|
lines := []string{"nexarch-mail greets " + arg, "8BITMIME"}
|
|
if s.tlsConfig != nil && !s.tlsActive {
|
|
lines = append(lines, "STARTTLS")
|
|
}
|
|
return s.replyMultiline(250, lines) == nil
|
|
}
|
|
return s.reply(250, "nexarch-mail greets "+arg) == nil
|
|
}
|
|
|
|
// handleStartTLS implementiert STARTTLS (RFC 3207): TLS-Aufwertung der
|
|
// bestehenden Klartextverbindung.
|
|
func (s *Session) handleStartTLS(ctx context.Context) bool {
|
|
if s.tlsConfig == nil {
|
|
return s.reply(454, "TLS not available") == nil
|
|
}
|
|
if s.tlsActive {
|
|
return s.reply(503, "already using TLS") == nil
|
|
}
|
|
if err := s.reply(220, "Ready to start TLS"); 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: 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
|
|
// Envelope nach STARTTLS zurücksetzen (RFC 3207 §4.2).
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
return true
|
|
}
|
|
|
|
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
|
// die Absenderadresse wird vor der Annahme validiert.
|
|
func (s *Session) handleMailFrom(arg string) bool {
|
|
if s.state == Greeting {
|
|
return s.reply(503, "send HELO/EHLO first") == nil
|
|
}
|
|
addr, err := parseMailAddressArg(arg, "FROM")
|
|
if err != nil {
|
|
return s.reply(501, "invalid MAIL FROM syntax") == nil
|
|
}
|
|
if err := validateAddress(addr); err != nil {
|
|
// Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere
|
|
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
|
return s.reply(553, "invalid sender address") == nil
|
|
}
|
|
s.from = addr
|
|
s.to = nil
|
|
s.state = MailFromSet
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
|
// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO
|
|
// sind erlaubt.
|
|
func (s *Session) handleRcptTo(arg string) bool {
|
|
if s.state != MailFromSet && s.state != RcptToSet {
|
|
return s.reply(503, "send MAIL FROM first") == nil
|
|
}
|
|
addr, err := parseMailAddressArg(arg, "TO")
|
|
if err != nil {
|
|
return s.reply(501, "invalid RCPT TO syntax") == nil
|
|
}
|
|
if err := validateAddress(addr); err != nil {
|
|
// Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere
|
|
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
|
return s.reply(553, "invalid recipient address") == nil
|
|
}
|
|
s.to = append(s.to, addr)
|
|
s.state = RcptToSet
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
func (s *Session) handleRset() bool {
|
|
s.from = ""
|
|
s.to = nil
|
|
if s.state != Greeting {
|
|
s.state = Ready
|
|
}
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
// handleData verlangt einen vollständig aufgebauten und validierten
|
|
// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden
|
|
// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur
|
|
// Abschlusszeile ".".
|
|
func (s *Session) handleData(ctx context.Context) bool {
|
|
if s.state != RcptToSet {
|
|
return s.reply(503, "send MAIL FROM/RCPT TO first") == nil
|
|
}
|
|
if err := s.reply(354, "Start mail input; end with <CRLF>.<CRLF>"); err != nil {
|
|
return false
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if line == "." {
|
|
break
|
|
}
|
|
// Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile,
|
|
// die mit "." beginnt, verliert genau diesen ersten Punkt.
|
|
line = strings.TrimPrefix(line, ".")
|
|
buf.WriteString(line)
|
|
buf.WriteString("\r\n")
|
|
|
|
if int64(buf.Len()) > s.maxMessageBytes {
|
|
// Akzeptanzkriterium 1: Nachrichtengröße wird VOR der
|
|
// endgültigen Annahme geprüft — sauberer Fehlercode statt
|
|
// unbegrenztem Pufferwachstum.
|
|
_ = s.drainUntilDot()
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
return s.reply(552, "message size exceeds fixed maximum message size") == nil
|
|
}
|
|
}
|
|
|
|
envelope := Envelope{From: s.from, To: s.to}
|
|
raw := buf.Bytes()
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
|
|
if s.sink != nil {
|
|
if err := s.sink.Accept(ctx, envelope, raw); err != nil {
|
|
return s.reply(451, "unable to accept message, try again later") == nil
|
|
}
|
|
}
|
|
return s.reply(250, "OK: message accepted") == nil
|
|
}
|