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