From b22ab67bb2728ea64b9d92e2c60d19c44934f4ea Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 1 Sep 2026 01:17:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20ING-06=20TLS/STARTTLS-Absicherung?= =?UTF-8?q?=20f=C3=BCr=20IMAP/POP3/SMTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mail/docs/ING-06-PRUEFPROTOKOLL.md | 165 +++++++++++++++ mail/internal/imap/commands.go | 47 ++++- mail/internal/imap/server.go | 26 ++- mail/internal/imap/session.go | 24 ++- mail/internal/imap/tls_test.go | 182 ++++++++++++++++ mail/internal/pop3/commands.go | 43 ++++ mail/internal/pop3/server.go | 25 ++- mail/internal/pop3/session.go | 31 ++- mail/internal/pop3/tls_test.go | 185 +++++++++++++++++ mail/internal/smtp/commands.go | 42 +++- mail/internal/smtp/server.go | 17 +- mail/internal/smtp/session.go | 11 +- mail/internal/smtp/tls_test.go | 285 ++++++++++++++++++++++++++ mail/internal/tlscert/testcert.go | 47 +++++ mail/internal/tlscert/tlscert.go | 104 ++++++++++ mail/internal/tlscert/tlscert_test.go | 67 ++++++ 16 files changed, 1263 insertions(+), 38 deletions(-) create mode 100644 mail/docs/ING-06-PRUEFPROTOKOLL.md create mode 100644 mail/internal/imap/tls_test.go create mode 100644 mail/internal/pop3/tls_test.go create mode 100644 mail/internal/smtp/tls_test.go create mode 100644 mail/internal/tlscert/testcert.go create mode 100644 mail/internal/tlscert/tlscert.go create mode 100644 mail/internal/tlscert/tlscert_test.go diff --git a/mail/docs/ING-06-PRUEFPROTOKOLL.md b/mail/docs/ING-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..e755f05 --- /dev/null +++ b/mail/docs/ING-06-PRUEFPROTOKOLL.md @@ -0,0 +1,165 @@ +# ING-06 — TLS/STARTTLS-Absicherung: Prüfprotokoll + +Datum: 2026-09-01 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Pakete: `mail/internal/tlscert` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3`, `mail/internal/smtp` + +## Umsetzung + +Neues Paket `tlscert` kapselt die für alle drei Protokollserver +gemeinsame TLS-Grundlage: + +- `Store` hält das aktuell aktive Zertifikat hinter `GetCertificate` + (wird von `crypto/tls` bei JEDEM neuen Handshake aufgerufen). + `Replace`/`ReplaceFromFiles` tauschen es atomar aus — bereits + etablierte Verbindungen behalten ihr beim Handshake ausgehandeltes + Zertifikat, nur NEUE Handshakes bekommen das neue (Akzeptanzkriterium + 3). +- `Store.Config()` liefert eine gehärtete `tls.Config`: `MinVersion: + tls.VersionTLS12`, für TLS 1.2 ausschließlich AEAD-Cipher-Suiten + (kein CBC, kein RC4, kein 3DES) — TLS 1.3 hat ohnehin nur starke, + feste Suiten (Akzeptanzkriterium 2). +- `UpgradeServer` führt den STARTTLS-Serverhandschlag durch, gemeinsam + genutzt von allen drei Protokollen. + +**IMAP** (`STARTTLS`, RFC 3501 §6.2.1), **POP3** (`STLS`, RFC 2595 §4) +und **SMTP** (`STARTTLS`, RFC 3207) bekommen je ein neues Kommando: nur +vor der Anmeldung erlaubt, Reader/Writer werden nach dem Handschlag +NEU aufgesetzt (verhindert, dass vor dem Handshake gepufferte +Klartextdaten als Kommandos nach dem Wechsel verarbeitet werden — +Command-Injection-Schutz). LOGIN (IMAP) und PASS (POP3) werden +zurückgewiesen, solange der Server TLS anbietet, aber die Verbindung +weder implizit (via `tls.Conn`) noch per STARTTLS/STLS verschlüsselt +ist (Akzeptanzkriterium 1). SMTP hat in der aktuellen minimalen +Implementierung (ING-03) kein Anmeldekommando (kein AUTH) — dort wird +STARTTLS strukturell bereitgestellt und geprüft, die +Anmeldedaten-Kernprüfung erfolgt für IMAP/POP3. + +Implizites TLS (z. B. Port 993/995/465) benötigt KEINE Codeänderung: +`Server.Serve` nimmt jeden `net.Listener` entgegen, ein mit +`tls.NewListener` gewrapptes Listener liefert bereits `*tls.Conn` aus +`Accept()` — die Session erkennt das per Typ-Assertion und startet +direkt mit `tlsActive = true`. + +Alle drei Server bleiben ohne TLS-Konfiguration (`tlsConfig == nil`) +unverändert im bisherigen Klartextverhalten — Rückwärtskompatibilität +zu ING-01/ING-02/ING-03, bestehende Tests unverändert grün. + +## Pflichtprüfung 1: Scan mit Standard-TLS-Prüfwerkzeug bestätigt keine schwachen Suiten + +Manuelle Prüfung mit `openssl s_client` (Standardwerkzeug, bereits auf +dem Zielsystem vorhanden) gegen einen echten, laufenden +`mail/internal/smtp`-Server mit aktivierter TLS-Konfiguration: + +``` +$ printf 'EHLO test\r\nQUIT\r\n' | openssl s_client -connect 127.0.0.1:14425 -starttls smtp -brief +CONNECTION ESTABLISHED +Protocol version: TLSv1.3 +Ciphersuite: TLS_AES_128_GCM_SHA256 +... +250 STARTTLS +DONE +``` + +→ Reguläre Verbindung: TLS 1.3, starke AEAD-Suite. Erzwungener Versuch +mit ausschließlich schwachen TLS-1.2-CBC-Suiten: + +``` +$ openssl s_client -connect 127.0.0.1:14425 -starttls smtp -tls1_2 \ + -cipher 'ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES128-SHA:AES256-SHA' +... +New, (NONE), Cipher is (NONE) +Cipher : 0000 +``` + +→ Kein Cipher ausgehandelt = Handshake fehlgeschlagen, Server nimmt +keine der angebotenen CBC-Suiten an. + +**Ergänzung/Abweichung dokumentiert:** Das auf diesem Host installierte +`openssl 3.5.6` verweigert es, TLS 1.0/1.1 überhaupt CLIENTSEITIG +anzufordern (`no protocols available`, auch mit `-provider legacy`) — +das lässt sich mit dem verfügbaren Standardwerkzeug nicht mehr +erzwingen. Als reproduzierbarer automatisierter Ersatz für den +Versions-Anteil dieser Prüfung: +`TestServer_RejectsLegacyTLSVersionAndWeakCiphers` (`smtp/tls_test.go`, +echter TCP-Client über `crypto/tls`, `MaxVersion: tls.VersionTLS11`) +gegen den echten Server — Handshake schlägt fehl. Zweiter Subtest +erzwingt clientseitig ausschließlich `TLS_RSA_WITH_AES_128_CBC_SHA` — +Handshake schlägt ebenfalls fehl. Zusätzlich +`TestConfig_HardenedDefaults` (`tlscert/tlscert_test.go`) prüft die +`tls.Config` direkt gegen eine Liste bekannter schwacher Suiten. + +Ergebnis: **BESTANDEN** (openssl-Scan + zwei automatisierte +Negativtests + Config-Assertion). + +## Pflichtprüfung 2: Login-Versuch ohne TLS/STARTTLS wird verweigert + +- `TestPass_RequiresTLS` (`pop3/tls_test.go`): PASS ohne vorheriges + STLS liefert `-ERR`. +- `TestLogin_RequiresTLS` (`imap/tls_test.go`): LOGIN ohne vorheriges + STARTTLS liefert `NO`. +- Kehrseite jeweils mitgetestet: `TestStls_UpgradesConnectionAndAllowsLogin` + bzw. `TestStartTLS_UpgradesConnectionAndAllowsLogin` — nach echtem + STLS/STARTTLS-Handschlag (reale `crypto/tls`-Clientverbindung) wird + dieselbe Anmeldung akzeptiert. +- SMTP: `TestStartTLS_UpgradesConnection` belegt den echten + STARTTLS-Handschlag strukturell (kein Anmeldekommando in der + aktuellen SMTP-Implementierung vorhanden, siehe Abschnitt + "Umsetzung"). + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 3: Zertifikatsrotation im laufenden Betrieb ohne Dienstunterbrechung + +`TestTLS_CertificateRotationWithoutDroppingExistingSessions` in allen +drei Protokollpaketen (`pop3`, `imap`, `smtp`): echter Ablauf — + +1. Erste TLS-Verbindung (echter Handschlag) aufbauen, bestätigen, dass + sie Zertifikat A bekommt, Verbindung OFFEN halten. +2. `store.Replace(certB)` — Rotation im laufenden Betrieb. +3. Zweite, NEUE Verbindung aufbauen — bekommt nachweislich Zertifikat + B (`PeerCertificates[0].Raw` verglichen). +4. Erste, bereits etablierte Verbindung wird DANACH weiterbenutzt + (POP3: USER/PASS, IMAP: LOGIN, SMTP: NOOP) — funktioniert + unterbrechungsfrei weiter. + +Zusätzlich `TestStore_ReplaceAffectsOnlyNewHandshakes` +(`tlscert/tlscert_test.go`) auf Store-Ebene. + +Ergebnis: **BESTANDEN** — in allen drei Protokollen: kein +Verbindungsabriss für die bestehende Session, neue Verbindungen +bekommen sofort das neue Zertifikat. + +## Akzeptanzkriterien + +1. **Anmeldedaten werden ausschließlich über TLS oder nach + erfolgreichem STARTTLS akzeptiert**: durch Pflichtprüfung 2 belegt + (IMAP LOGIN, POP3 PASS). +2. **Schwache Cipher-Suiten und veraltete TLS-Versionen sind + serverseitig deaktiviert**: durch Pflichtprüfung 1 belegt + (`tlscert.Store.Config()`: `MinVersion: TLS12`, ausschließlich + AEAD-Suiten für TLS 1.2). +3. **Zertifikatswechsel ist ohne Verbindungsabriss für bestehende + Sessions möglich**: durch Pflichtprüfung 3 belegt. + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +go build ./... → OK +go vet ./... → OK +golangci-lint run ./... → 0 issues +go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuem internal/tlscert +``` + +Keine Regression in den bestehenden ~27 Paketen. Manueller +TLS-Testserver (`cmd/tls-manual-test`) und dessen Hintergrundprozess +nach den openssl-Prüfungen entfernt/beendet, nicht im Repository +verblieben. + +## Ergebnis + +ING-06 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten +Nachweisen. Pflichtprüfung 1 (Versions-Anteil) wurde mangels +clientseitig erzwingbarem Legacy-TLS im installierten openssl 3.5.6 +zusätzlich durch einen echten automatisierten `crypto/tls`-Negativtest +gegen den laufenden Server ergänzt — siehe Abschnitt oben. Freigeschaltet: QA-04. diff --git a/mail/internal/imap/commands.go b/mail/internal/imap/commands.go index 714a332..6b28b3b 100644 --- a/mail/internal/imap/commands.go +++ b/mail/internal/imap/commands.go @@ -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 diff --git a/mail/internal/imap/server.go b/mail/internal/imap/server.go index 5bad0d9..aaad656 100644 --- a/mail/internal/imap/server.go +++ b/mail/internal/imap/server.go @@ -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) } } diff --git a/mail/internal/imap/session.go b/mail/internal/imap/session.go index 0b30b3d..608603a 100644 --- a/mail/internal/imap/session.go +++ b/mail/internal/imap/session.go @@ -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") } diff --git a/mail/internal/imap/tls_test.go b/mail/internal/imap/tls_test.go new file mode 100644 index 0000000..43aba2c --- /dev/null +++ b/mail/internal/imap/tls_test.go @@ -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) + } +} diff --git a/mail/internal/pop3/commands.go b/mail/internal/pop3/commands.go index d908e0f..74901db 100644 --- a/mail/internal/pop3/commands.go +++ b/mail/internal/pop3/commands.go @@ -1,10 +1,13 @@ 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, @@ -38,6 +41,12 @@ func (s *Session) handlePass(ctx context.Context, cmd command) bool { 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 } @@ -208,3 +217,37 @@ func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (in } 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 +} diff --git a/mail/internal/pop3/server.go b/mail/internal/pop3/server.go index ce30866..3fb4715 100644 --- a/mail/internal/pop3/server.go +++ b/mail/internal/pop3/server.go @@ -2,6 +2,7 @@ package pop3 import ( "context" + "crypto/tls" "errors" "fmt" "net" @@ -11,12 +12,17 @@ import ( // Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen // Goroutine (Akzeptanzkriterium 1) — gleiches Muster wie -// mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht -// dieser Kachel. +// mail/internal/imap.Server. tlsConfig ist optional: nil bedeutet, STLS +// ist nicht verfügbar und USER/PASS sind wie vor ING-06 uneingeschränkt +// erlaubt (Klartextbetrieb, z. B. hinter einem separaten TLS-Terminator +// oder in Tests); ist tlsConfig gesetzt, verlangt PASS zwingend STLS +// 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 { @@ -30,6 +36,13 @@ func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg p return &Server{auth: auth, store: store, guardCfg: guardCfg} } +// NewServerWithGuardAndTLSConfig erlaubt zusätzlich, STLS/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. func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { go func() { @@ -49,7 +62,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { } return fmt.Errorf("pop3: 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) } } diff --git a/mail/internal/pop3/session.go b/mail/internal/pop3/session.go index 7bbd11d..66cd755 100644 --- a/mail/internal/pop3/session.go +++ b/mail/internal/pop3/session.go @@ -3,6 +3,7 @@ package pop3 import ( "bufio" "context" + "crypto/tls" "errors" "io" "net" @@ -34,22 +35,32 @@ type Session struct { store MailboxStore guard *protoguard.Guard + // tlsConfig ist nil, wenn dieser Server ohne TLS/STLS betrieben wird + // (Akzeptanzkriterium 1 greift dann nicht — Rückwärtskompatibilität + // zu ING-01/ING-02). tlsActive ist von Anfang an true, wenn die + // Verbindung bereits über einen impliziten TLS-Listener hereinkam. + tlsConfig *tls.Config + tlsActive bool + state State pendingUsername string // nach USER, vor erfolgreichem PASS username string // nach erfolgreichem PASS deleted map[int]bool } -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: Authorization, - deleted: map[int]bool{}, + conn: conn, + reader: bufio.NewReaderSize(conn, maxCommandLineBytes), + writer: bufio.NewWriter(conn), + auth: auth, + store: store, + guard: protoguard.New(guardCfg), + tlsConfig: tlsConfig, + tlsActive: alreadyTLS, + state: Authorization, + deleted: map[int]bool{}, } } @@ -139,6 +150,8 @@ func (s *Session) dispatch(ctx context.Context, cmd command) bool { return s.handleDele(cmd) case "QUIT": return s.handleQuit(ctx) + case "STLS": + return s.handleStls(ctx) default: return writeErr(s.writer, "unknown command") == nil } diff --git a/mail/internal/pop3/tls_test.go b/mail/internal/pop3/tls_test.go new file mode 100644 index 0000000..6bfeef0 --- /dev/null +++ b/mail/internal/pop3/tls_test.go @@ -0,0 +1,185 @@ +package pop3 + +import ( + "bufio" + "context" + "crypto/tls" + "net" + "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 := newFakeMailboxStore() + 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 + } +} + +// TestPass_RequiresTLS ist die geforderte Pflichtprüfung 2 (ING-06): +// Login-Versuch (hier: PASS) ohne TLS/STLS wird verweigert, sobald der +// Server TLS anbietet (Akzeptanzkriterium 1). +func TestPass_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') + + _, _ = conn.Write([]byte("USER alice\r\n")) + _, _ = reader.ReadString('\n') + _, _ = conn.Write([]byte("PASS geheim123\r\n")) + resp, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("antwort lesen: %v", err) + } + if resp[0] != '-' { + t.Fatalf("erwartete -ERR (TLS erforderlich), habe: %q", resp) + } +} + +// TestStls_UpgradesConnectionAndAllowsLogin belegt, dass PASS nach +// erfolgreichem STLS akzeptiert wird — die Kehrseite von +// TestPass_RequiresTLS. +func TestStls_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') + + _, _ = conn.Write([]byte("STLS\r\n")) + resp, _ := reader.ReadString('\n') + if resp[0] != '+' { + t.Fatalf("erwartete +OK für STLS, habe: %q", resp) + } + + tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) + if err := tlsConn.Handshake(); err != nil { + t.Fatalf("tls-handshake: %v", err) + } + tlsReader := bufio.NewReader(tlsConn) + + _, _ = tlsConn.Write([]byte("USER alice\r\n")) + _, _ = tlsReader.ReadString('\n') + _, _ = tlsConn.Write([]byte("PASS geheim123\r\n")) + resp, err = tlsReader.ReadString('\n') + if err != nil { + t.Fatalf("antwort über tls lesen: %v", err) + } + if resp[0] != '+' { + t.Fatalf("erwartete +OK nach STLS, habe: %q", resp) + } +} + +// TestTLS_CertificateRotationWithoutDroppingExistingSessions ist die +// geforderte Pflichtprüfung 3 (ING-06): Zertifikatsrotation im +// laufenden Betrieb ohne Dienstunterbrechung — eine bereits etablierte +// TLS-Session bleibt nach dem Wechsel 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() + + // Erste Verbindung, VOR der Rotation aufgebaut, wird offen gehalten. + 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') + _, _ = firstConn.Write([]byte("STLS\r\n")) + _, _ = r1.ReadString('\n') + firstTLS := tls.Client(firstConn, &tls.Config{InsecureSkipVerify: true}) + if err := firstTLS.Handshake(); err != nil { + t.Fatalf("tls-handshake 1: %v", err) + } + firstPeerCert := firstTLS.ConnectionState().PeerCertificates[0].Raw + if string(firstPeerCert) != string(certA.Certificate[0]) { + t.Fatalf("erste verbindung hätte zertifikat a bekommen sollen") + } + + // Rotation im laufenden Betrieb. + store.Replace(certB) + + // Zweite, NEUE Verbindung bekommt das neue Zertifikat. + 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') + _, _ = secondConn.Write([]byte("STLS\r\n")) + _, _ = r2.ReadString('\n') + secondTLS := tls.Client(secondConn, &tls.Config{InsecureSkipVerify: true}) + if err := secondTLS.Handshake(); err != nil { + t.Fatalf("tls-handshake 2: %v", err) + } + secondPeerCert := secondTLS.ConnectionState().PeerCertificates[0].Raw + if string(secondPeerCert) != string(certB.Certificate[0]) { + t.Fatalf("zweite verbindung hätte NACH der rotation zertifikat b bekommen sollen") + } + + // Erste, bereits etablierte Verbindung ist nach der Rotation weiter + // UNGESTÖRT nutzbar (kein Verbindungsabriss). + tlsReader1 := bufio.NewReader(firstTLS) + _, _ = firstTLS.Write([]byte("USER alice\r\n")) + _, _ = tlsReader1.ReadString('\n') + _, _ = firstTLS.Write([]byte("PASS geheim123\r\n")) + resp, err := tlsReader1.ReadString('\n') + if err != nil { + t.Fatalf("erste verbindung nach rotation unterbrochen: %v", err) + } + if resp[0] != '+' { + t.Fatalf("erste verbindung nach rotation nicht mehr funktionsfähig: %q", resp) + } +} diff --git a/mail/internal/smtp/commands.go b/mail/internal/smtp/commands.go index 15509fe..45a6ec6 100644 --- a/mail/internal/smtp/commands.go +++ b/mail/internal/smtp/commands.go @@ -1,9 +1,12 @@ package smtp import ( + "bufio" "bytes" "context" "strings" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert" ) func (s *Session) handleHelo(verb, arg string) bool { @@ -17,11 +20,48 @@ func (s *Session) handleHelo(verb, arg string) bool { s.state = Ready if verb == "EHLO" { - return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil + 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 { diff --git a/mail/internal/smtp/server.go b/mail/internal/smtp/server.go index 1a39557..05617d0 100644 --- a/mail/internal/smtp/server.go +++ b/mail/internal/smtp/server.go @@ -2,6 +2,7 @@ package smtp import ( "context" + "crypto/tls" "errors" "fmt" "net" @@ -13,12 +14,13 @@ const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB // Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen // Goroutine — gleiches Muster wie mail/internal/imap.Server und -// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06, -// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 — -// keine dieser Kacheln. +// mail/internal/pop3.Server. tlsConfig ist optional (ING-06): nil +// bedeutet, STARTTLS ist nicht verfügbar. Rate-Limiting ist Sache von +// ING-09, Protokoll-Logging Sache von ING-08 — keine dieser Kacheln. type Server struct { sink MessageSink maxMessageBytes int64 + tlsConfig *tls.Config } func NewServer(sink MessageSink) *Server { @@ -31,6 +33,13 @@ func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Serv return &Server{sink: sink, maxMessageBytes: maxMessageBytes} } +// NewServerWithMaxMessageBytesAndTLSConfig erlaubt zusätzlich, STARTTLS +// zu aktivieren (ING-06). tlsConfig sollte über tlscert.Store.Config() +// erzeugt werden. +func NewServerWithMaxMessageBytesAndTLSConfig(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *Server { + return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig} +} + // Serve nimmt Verbindungen auf listener an, bis ctx beendet wird. func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { go func() { @@ -50,7 +59,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { } return fmt.Errorf("smtp: verbindung annehmen: %w", err) } - session := newSession(conn, srv.sink, srv.maxMessageBytes) + session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig) go session.Serve(ctx) } } diff --git a/mail/internal/smtp/session.go b/mail/internal/smtp/session.go index 8028717..47d4d0f 100644 --- a/mail/internal/smtp/session.go +++ b/mail/internal/smtp/session.go @@ -3,6 +3,7 @@ package smtp import ( "bufio" "context" + "crypto/tls" "errors" "io" "net" @@ -25,18 +26,24 @@ type Session struct { 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) *Session { +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, } } @@ -93,6 +100,8 @@ func (s *Session) dispatch(ctx context.Context, verb, arg string) bool { 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": diff --git a/mail/internal/smtp/tls_test.go b/mail/internal/smtp/tls_test.go new file mode 100644 index 0000000..65a6fb8 --- /dev/null +++ b/mail/internal/smtp/tls_test.go @@ -0,0 +1,285 @@ +package smtp + +import ( + "bufio" + "context" + "crypto/tls" + "net" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert" +) + +func startTLSTestServer(t *testing.T, sink MessageSink, tlsConfig *tls.Config) (addr string, stop func()) { + t.Helper() + srv := NewServerWithMaxMessageBytesAndTLSConfig(sink, defaultMaxMessageBytes, 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 + } +} + +// TestEHLO_AdvertisesStartTLSUntilActive belegt, dass STARTTLS +// angeboten wird, solange keine TLS-Verbindung aktiv ist, und danach +// nicht mehr (Akzeptanzkriterium 1: Grundlage, TLS wird angeboten). +func TestEHLO_AdvertisesStartTLSUntilActive(t *testing.T) { + cert, err := tlscert.GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("testzertifikat erzeugen: %v", err) + } + store := tlscert.NewStore(cert) + sink := &fakeSink{} + addr, stop := startTLSTestServer(t, sink, 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') + + _, _ = conn.Write([]byte("EHLO client.example.com\r\n")) + var sawStartTLS bool + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("antwort lesen: %v", err) + } + if strings.Contains(line, "STARTTLS") { + sawStartTLS = true + } + if strings.HasPrefix(line, "250 ") { + break + } + } + if !sawStartTLS { + t.Fatalf("erwartete STARTTLS in EHLO-Antwort, wenn TLS konfiguriert ist") + } +} + +// TestStartTLS_UpgradesConnection ist die geforderte Pflichtprüfung 2 +// (ING-06) für SMTP: ein echter STARTTLS-Handschlag etabliert eine +// reale TLS-Verbindung. SMTP hat in dieser minimalen Implementierung +// (ING-03) kein Anmeldekommando (kein AUTH) — die +// Akzeptanzkriterium-1-Prüfung "Login ohne TLS wird verweigert" ist +// daher für IMAP/POP3 (dort real vorhanden) durchgeführt, siehe deren +// Pakete. Hier wird strukturell geprüft: STARTTLS baut eine ECHTE +// TLS-Sitzung auf, danach wird kein STARTTLS mehr angeboten. +func TestStartTLS_UpgradesConnection(t *testing.T) { + cert, err := tlscert.GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("testzertifikat erzeugen: %v", err) + } + store := tlscert.NewStore(cert) + sink := &fakeSink{} + addr, stop := startTLSTestServer(t, sink, 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') + _, _ = conn.Write([]byte("EHLO client.example.com\r\n")) + for { + line, _ := reader.ReadString('\n') + if strings.HasPrefix(line, "250 ") { + break + } + } + + _, _ = conn.Write([]byte("STARTTLS\r\n")) + resp, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("antwort lesen: %v", err) + } + if code(resp) != "220" { + t.Fatalf("erwartete 220 für STARTTLS, habe: %s", resp) + } + + tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) + if err := tlsConn.Handshake(); err != nil { + t.Fatalf("tls-handshake: %v", err) + } + if string(tlsConn.ConnectionState().PeerCertificates[0].Raw) != string(cert.Certificate[0]) { + t.Fatalf("tls-verbindung hätte das konfigurierte zertifikat liefern sollen") + } + + // Kommandos funktionieren über die TLS-Verbindung normal weiter, + // EHLO bietet STARTTLS nun nicht mehr an (bereits aktiv). + tlsReader := bufio.NewReader(tlsConn) + _, _ = tlsConn.Write([]byte("EHLO client.example.com\r\n")) + var sawStartTLS bool + for { + line, err := tlsReader.ReadString('\n') + if err != nil { + t.Fatalf("antwort über tls lesen: %v", err) + } + if strings.Contains(line, "STARTTLS") { + sawStartTLS = true + } + if strings.HasPrefix(line, "250 ") { + break + } + } + if sawStartTLS { + t.Fatalf("STARTTLS sollte nach aktivem TLS nicht mehr angeboten werden") + } +} + +// TestServer_RejectsLegacyTLSVersionAndWeakCiphers ist Teil der +// geforderten Pflichtprüfung 1 (ING-06, Akzeptanzkriterium 2): ein +// echter TLS-Client, der ausschließlich veraltete Protokollversionen +// bzw. schwache (CBC-)Cipher-Suiten anbietet, bekommt vom Server KEINE +// erfolgreiche Verbindung. Ergänzt die manuelle openssl-Prüfung aus dem +// Prüfprotokoll um einen automatisierten, reproduzierbaren Nachweis. +func TestServer_RejectsLegacyTLSVersionAndWeakCiphers(t *testing.T) { + cert, err := tlscert.GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("testzertifikat erzeugen: %v", err) + } + store := tlscert.NewStore(cert) + sink := &fakeSink{} + addr, stop := startTLSTestServer(t, sink, store.Config()) + defer stop() + + t.Run("veraltete protokollversion (max TLS 1.1)", func(t *testing.T) { + conn := dialAndStartTLS(t, addr) + defer func() { _ = conn.Close() }() + //nolint:staticcheck // veraltete Version ABSICHTLICH angefordert, um die Ablehnung zu prüfen + client := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, MaxVersion: tls.VersionTLS11}) + if err := client.Handshake(); err == nil { + t.Fatalf("erwartete fehlgeschlagenen handshake mit max TLS 1.1") + } + }) + + t.Run("nur schwache CBC-cipher-suite angeboten", func(t *testing.T) { + conn := dialAndStartTLS(t, addr) + defer func() { _ = conn.Close() }() + client := tls.Client(conn, &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{tls.TLS_RSA_WITH_AES_128_CBC_SHA}, + }) + if err := client.Handshake(); err == nil { + t.Fatalf("erwartete fehlgeschlagenen handshake mit ausschließlich schwacher cipher-suite") + } + }) +} + +func dialAndStartTLS(t *testing.T, addr string) net.Conn { + t.Helper() + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + reader := bufio.NewReader(conn) + _, _ = reader.ReadString('\n') + _, _ = conn.Write([]byte("EHLO client.example.com\r\n")) + for { + line, _ := reader.ReadString('\n') + if strings.HasPrefix(line, "250 ") { + break + } + } + _, _ = conn.Write([]byte("STARTTLS\r\n")) + _, _ = reader.ReadString('\n') + return conn +} + +// TestTLS_CertificateRotationWithoutDroppingExistingSessions ist die +// geforderte Pflichtprüfung 3 (ING-06) für SMTP. +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) + sink := &fakeSink{} + addr, stop := startTLSTestServer(t, sink, 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') + _, _ = firstConn.Write([]byte("EHLO client.example.com\r\n")) + for { + line, _ := r1.ReadString('\n') + if strings.HasPrefix(line, "250 ") { + break + } + } + _, _ = firstConn.Write([]byte("STARTTLS\r\n")) + _, _ = r1.ReadString('\n') + 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') + _, _ = secondConn.Write([]byte("EHLO client.example.com\r\n")) + for { + line, _ := r2.ReadString('\n') + if strings.HasPrefix(line, "250 ") { + break + } + } + _, _ = secondConn.Write([]byte("STARTTLS\r\n")) + _, _ = r2.ReadString('\n') + 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 TLS-Verbindung bleibt ungestört nutzbar. + tlsReader1 := bufio.NewReader(firstTLS) + if _, err := firstTLS.Write([]byte("NOOP\r\n")); err != nil { + t.Fatalf("NOOP über erste verbindung nach rotation: %v", err) + } + resp, err := tlsReader1.ReadString('\n') + if err != nil { + t.Fatalf("erste verbindung nach rotation unterbrochen: %v", err) + } + if code(resp) != "250" { + t.Fatalf("erste verbindung nach rotation nicht mehr funktionsfähig: %q", resp) + } +} diff --git a/mail/internal/tlscert/testcert.go b/mail/internal/tlscert/testcert.go new file mode 100644 index 0000000..1a3c94e --- /dev/null +++ b/mail/internal/tlscert/testcert.go @@ -0,0 +1,47 @@ +package tlscert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "time" +) + +// GenerateSelfSigned erzeugt ein frisches, selbstsigniertes ECDSA-P256- +// Zertifikat für host (z. B. "127.0.0.1") — ausschließlich für Tests +// dieses Moduls, kein Ersatz für echte Zertifikatsverwaltung im Betrieb. +func GenerateSelfSigned(host string) (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("tlscert: schlüssel erzeugen: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("tlscert: seriennummer erzeugen: %w", err) + } + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if ip := net.ParseIP(host); ip != nil { + template.IPAddresses = []net.IP{ip} + } else { + template.DNSNames = []string{host} + } + + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("tlscert: zertifikat erzeugen: %w", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil +} diff --git a/mail/internal/tlscert/tlscert.go b/mail/internal/tlscert/tlscert.go new file mode 100644 index 0000000..f0824c0 --- /dev/null +++ b/mail/internal/tlscert/tlscert.go @@ -0,0 +1,104 @@ +// Package tlscert bündelt die für ING-06 gemeinsam benötigte +// TLS-Grundlage von IMAP-, POP3- und SMTP-Server: ein hot-reloadbares +// Zertifikat (Akzeptanzkriterium 3: Zertifikatswechsel ohne +// Verbindungsabriss für bestehende Sessions) und eine gehärtete +// tls.Config (Akzeptanzkriterium 2: keine schwachen Cipher-Suiten, +// keine veralteten TLS-Versionen). +package tlscert + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "sync" +) + +// Store hält das aktuell aktive Zertifikat. GetCertificate wird von +// crypto/tls bei JEDEM neuen Handshake aufgerufen und liefert dadurch +// immer den aktuellen Stand — ein Replace wirkt sich nur auf NEUE +// Handshakes aus, bereits etablierte Verbindungen laufen mit ihrem beim +// Handshake ausgehandelten Zertifikat unverändert weiter (kein +// Verbindungsabriss, Akzeptanzkriterium 3). +type Store struct { + mu sync.RWMutex + cert *tls.Certificate +} + +// NewStore erstellt einen Store mit einem bereits geladenen Zertifikat. +func NewStore(cert tls.Certificate) *Store { + return &Store{cert: &cert} +} + +// LoadFromFiles lädt Zertifikat und privaten Schlüssel aus PEM-Dateien. +// Die Pfade kommen ausschließlich vom Aufrufer (Umgebungsvariable), +// nicht aus dem Code. +func LoadFromFiles(certPath, keyPath string) (*Store, error) { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("tlscert: zertifikat laden: %w", err) + } + return NewStore(cert), nil +} + +// Replace tauscht das aktive Zertifikat atomar aus. +func (s *Store) Replace(cert tls.Certificate) { + s.mu.Lock() + defer s.mu.Unlock() + s.cert = &cert +} + +// ReplaceFromFiles lädt ein neues Zertifikat aus Dateien und ersetzt +// damit das aktive (z. B. nach Zertifikatserneuerung durch eine externe +// ACME-Komponente). +func (s *Store) ReplaceFromFiles(certPath, keyPath string) error { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return fmt.Errorf("tlscert: zertifikat laden: %w", err) + } + s.Replace(cert) + return nil +} + +// GetCertificate erfüllt tls.Config.GetCertificate. +func (s *Store) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cert, nil +} + +// hardenedCipherSuites sind ausschließlich AEAD-Suiten für TLS 1.2 +// (kein CBC, kein RC4, kein 3DES) — Akzeptanzkriterium 2. TLS 1.3 hat +// ohnehin nur feste, starke Suiten und braucht keine explizite Liste. +var hardenedCipherSuites = []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, +} + +// Config liefert eine gehärtete tls.Config: TLS 1.2 als Mindestversion +// (Akzeptanzkriterium 2, deaktiviert SSLv3/TLS1.0/TLS1.1), ausschließlich +// AEAD-Cipher-Suiten für TLS 1.2, Zertifikat immer über s.GetCertificate +// aufgelöst (Akzeptanzkriterium 3). +func (s *Store) Config() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: hardenedCipherSuites, + GetCertificate: s.GetCertificate, + } +} + +// UpgradeServer führt den TLS-Serverhandschlag auf einer bestehenden +// Klartextverbindung durch (STARTTLS-Muster, gemeinsam genutzt von +// IMAP-, POP3- und SMTP-Session) und liefert die resultierende +// *tls.Conn. +func UpgradeServer(ctx context.Context, conn net.Conn, cfg *tls.Config) (*tls.Conn, error) { + tlsConn := tls.Server(conn, cfg) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return nil, err + } + return tlsConn, nil +} diff --git a/mail/internal/tlscert/tlscert_test.go b/mail/internal/tlscert/tlscert_test.go new file mode 100644 index 0000000..bf06586 --- /dev/null +++ b/mail/internal/tlscert/tlscert_test.go @@ -0,0 +1,67 @@ +package tlscert + +import ( + "crypto/tls" + "testing" +) + +func TestConfig_HardenedDefaults(t *testing.T) { + cert, err := GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("testzertifikat erzeugen: %v", err) + } + store := NewStore(cert) + cfg := store.Config() + + if cfg.MinVersion < tls.VersionTLS12 { + t.Fatalf("erwartete mindestens TLS 1.2, habe: 0x%04x", cfg.MinVersion) + } + for _, id := range cfg.CipherSuites { + for _, weak := range []uint16{ + tls.TLS_RSA_WITH_RC4_128_SHA, + tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + } { + if id == weak { + t.Fatalf("schwache cipher-suite in gehärteter config gefunden: 0x%04x", id) + } + } + } + if len(cfg.CipherSuites) == 0 { + t.Fatalf("erwartete explizite cipher-suite-liste, habe keine") + } +} + +// TestStore_ReplaceAffectsOnlyNewHandshakes ist die geforderte +// Pflichtprüfung 3 (ING-06) auf Store-Ebene: nach Replace liefert +// GetCertificate sofort das neue Zertifikat für neue Handshakes. +func TestStore_ReplaceAffectsOnlyNewHandshakes(t *testing.T) { + certA, err := GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("zertifikat a erzeugen: %v", err) + } + certB, err := GenerateSelfSigned("127.0.0.1") + if err != nil { + t.Fatalf("zertifikat b erzeugen: %v", err) + } + store := NewStore(certA) + + got, err := store.GetCertificate(nil) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + if string(got.Certificate[0]) != string(certA.Certificate[0]) { + t.Fatalf("erwartete zertifikat a vor dem wechsel") + } + + store.Replace(certB) + + got, err = store.GetCertificate(nil) + if err != nil { + t.Fatalf("GetCertificate nach Replace: %v", err) + } + if string(got.Certificate[0]) != string(certB.Certificate[0]) { + t.Fatalf("erwartete zertifikat b nach dem wechsel") + } +}