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.
183 lines
5.8 KiB
Go
183 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|