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") + } +}