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.
254 lines
7.8 KiB
Go
254 lines
7.8 KiB
Go
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,
|
|
// ob der Benutzername unbekannt oder nur das Passwort falsch war
|
|
// (Akzeptanzkriterium 3: fehlerhafte Anmeldeversuche ohne
|
|
// Informationspreisgabe).
|
|
const genericAuthFailure = "authentication failed"
|
|
|
|
func (s *Session) handleUser(cmd command) bool {
|
|
if s.state != Authorization {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return writeErr(s.writer, "USER requires a username") == nil
|
|
}
|
|
// RFC 1939: USER antwortet immer mit +OK, unabhängig davon, ob der
|
|
// Name existiert — die eigentliche Prüfung passiert erst bei PASS
|
|
// (Akzeptanzkriterium 3: keine Informationspreisgabe schon an dieser
|
|
// Stelle).
|
|
s.pendingUsername = cmd.Args[0]
|
|
return writeOK(s.writer, "send PASS") == nil
|
|
}
|
|
|
|
func (s *Session) handlePass(ctx context.Context, cmd command) bool {
|
|
if s.state != Authorization {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if s.pendingUsername == "" {
|
|
return writeErr(s.writer, genericAuthFailure) == nil
|
|
}
|
|
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
|
|
}
|
|
|
|
ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0])
|
|
if err != nil || !ok {
|
|
// Backoff statt Dauerschleife bei wiederholten Fehlversuchen
|
|
// (Akzeptanzkriterium 3, ING-07). Immer derselbe generische Text,
|
|
// egal ob unbekannter Nutzer, falsches Passwort oder interner
|
|
// Fehler.
|
|
backoff, disconnect := s.guard.RecordAuthFailure()
|
|
s.guard.Wait(ctx, backoff)
|
|
if err := writeErr(s.writer, genericAuthFailure); err != nil {
|
|
return false
|
|
}
|
|
return !disconnect
|
|
}
|
|
|
|
s.guard.ResetAuthFailures()
|
|
s.username = s.pendingUsername
|
|
s.state = Transaction
|
|
return writeOK(s.writer, "maildrop locked and ready") == nil
|
|
}
|
|
|
|
func (s *Session) handleStat(ctx context.Context) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to read maildrop") == nil
|
|
}
|
|
var totalSize int64
|
|
for _, m := range messages {
|
|
totalSize += m.Size
|
|
}
|
|
return writeOK(s.writer, fmt.Sprintf("%d %d", len(messages), totalSize)) == nil
|
|
}
|
|
|
|
func (s *Session) handleList(ctx context.Context, cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to read maildrop") == nil
|
|
}
|
|
|
|
if len(cmd.Args) == 1 {
|
|
n, convErr := strconv.Atoi(cmd.Args[0])
|
|
if convErr != nil {
|
|
return writeErr(s.writer, "invalid message number") == nil
|
|
}
|
|
for _, m := range messages {
|
|
if m.Number == n {
|
|
return writeOK(s.writer, fmt.Sprintf("%d %d", m.Number, m.Size)) == nil
|
|
}
|
|
}
|
|
return writeErr(s.writer, "no such message") == nil
|
|
}
|
|
|
|
var totalSize int64
|
|
lines := make([]string, 0, len(messages))
|
|
for _, m := range messages {
|
|
totalSize += m.Size
|
|
lines = append(lines, fmt.Sprintf("%d %d", m.Number, m.Size))
|
|
}
|
|
return writeMultiline(s.writer, fmt.Sprintf("%d messages (%d octets)", len(messages), totalSize), strings.Join(lines, "\n")) == nil
|
|
}
|
|
|
|
func (s *Session) handleRetr(ctx context.Context, cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
n, err := s.parseActiveMessageNumber(ctx, cmd)
|
|
if err != nil {
|
|
return writeErr(s.writer, err.Error()) == nil
|
|
}
|
|
|
|
content, err := s.store.Retrieve(ctx, s.username, n)
|
|
if err != nil {
|
|
return writeErr(s.writer, "unable to retrieve message") == nil
|
|
}
|
|
// Akzeptanzkriterium 2: RETR liefert die VOLLSTÄNDIGE Nachricht.
|
|
return writeMultiline(s.writer, fmt.Sprintf("%d octets", len(content)), string(content)) == nil
|
|
}
|
|
|
|
func (s *Session) handleDele(cmd command) bool {
|
|
if s.state != Transaction {
|
|
return writeErr(s.writer, "command not valid in this state") == nil
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return writeErr(s.writer, "DELE requires a message number") == nil
|
|
}
|
|
n, err := strconv.Atoi(cmd.Args[0])
|
|
if err != nil {
|
|
return writeErr(s.writer, "invalid message number") == nil
|
|
}
|
|
if s.deleted[n] {
|
|
return writeErr(s.writer, "message already deleted") == nil
|
|
}
|
|
// NUR innerhalb der Sitzung markiert — endgültig gelöscht wird
|
|
// ausschließlich in handleQuit (Akzeptanzkriterium 2/Pflichtprüfung 3).
|
|
s.deleted[n] = true
|
|
return writeOK(s.writer, fmt.Sprintf("message %d deleted", n)) == nil
|
|
}
|
|
|
|
func (s *Session) handleQuit(ctx context.Context) bool {
|
|
if s.state != Transaction {
|
|
// Aus Authorization: keine Update-Phase, keine Löschungen möglich
|
|
// (es wurde noch nichts markiert).
|
|
_ = writeOK(s.writer, "goodbye")
|
|
return false
|
|
}
|
|
|
|
s.state = Update
|
|
if len(s.deleted) > 0 {
|
|
numbers := make([]int, 0, len(s.deleted))
|
|
for n := range s.deleted {
|
|
numbers = append(numbers, n)
|
|
}
|
|
if err := s.store.Delete(ctx, s.username, numbers); err != nil {
|
|
_ = writeErr(s.writer, "unable to update maildrop, changes not committed")
|
|
return false
|
|
}
|
|
}
|
|
_ = writeOK(s.writer, "goodbye")
|
|
return false
|
|
}
|
|
|
|
// activeMessages liefert alle Nachrichten, die in DIESER Sitzung noch
|
|
// nicht per DELE markiert wurden (RFC 1939: gelöschte Nachrichten sind
|
|
// für STAT/LIST/RETR ab dem Zeitpunkt der Markierung nicht mehr sichtbar,
|
|
// auch wenn die Löschung selbst erst bei QUIT endgültig wird).
|
|
func (s *Session) activeMessages(ctx context.Context) ([]Message, error) {
|
|
all, err := s.store.List(ctx, s.username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
active := make([]Message, 0, len(all))
|
|
for _, m := range all {
|
|
if !s.deleted[m.Number] {
|
|
active = append(active, m)
|
|
}
|
|
}
|
|
return active, nil
|
|
}
|
|
|
|
func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (int, error) {
|
|
if len(cmd.Args) != 1 {
|
|
return 0, fmt.Errorf("requires a message number")
|
|
}
|
|
n, err := strconv.Atoi(cmd.Args[0])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid message number")
|
|
}
|
|
if s.deleted[n] {
|
|
return 0, fmt.Errorf("message deleted")
|
|
}
|
|
messages, err := s.activeMessages(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("unable to read maildrop")
|
|
}
|
|
for _, m := range messages {
|
|
if m.Number == n {
|
|
return n, nil
|
|
}
|
|
}
|
|
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
|
|
}
|