Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22ab67bb2 | ||
|
|
6631bcbbdd |
@@ -0,0 +1,116 @@
|
||||
# ING-03 — SMTP-Server & Mailer: Prüfprotokoll
|
||||
|
||||
Datum: 2026-09-01
|
||||
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
|
||||
Pakete: `mail/internal/smtp` (SMTP-Server, neu), `mail/internal/mailer` (Mailer-Komponente, neu)
|
||||
|
||||
## Umsetzung
|
||||
|
||||
**`mail/internal/smtp`** — SMTP-Server (RFC 5321) für eingehende Mails,
|
||||
von Grund auf implementiert, architektonisch analog zu
|
||||
`mail/internal/imap`/`pop3`: TCP-Listener mit einer Goroutine pro
|
||||
Verbindung, Session-Zustandsmaschine (Greeting → Ready → MailFromSet →
|
||||
RcptToSet), Kommandos HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP,
|
||||
QUIT. Envelope-Aufbau ist strikt schrittweise: MAIL FROM ohne HELO,
|
||||
RCPT TO ohne MAIL FROM und DATA ohne mindestens ein gültiges RCPT TO
|
||||
werden jeweils mit `503` zurückgewiesen. Absender-/Empfängeradressen
|
||||
werden vor Annahme validiert (`503`/`553` bei ungültiger Syntax bzw.
|
||||
Steuerzeichen). Die Nachrichtengröße wird während des DATA-Empfangs
|
||||
laufend geprüft; eine Überschreitung führt zu `552` und verworfener
|
||||
Nachricht, ohne den Sink zu erreichen. Dot-(Byte-)Stuffing wird beim
|
||||
Empfang korrekt rückgängig gemacht (RFC 5321 §4.5.2).
|
||||
|
||||
**`mail/internal/mailer`** — Mailer-Komponente für ausgehende
|
||||
Nachrichten. `headerWriter` (`header.go`) ist die EINZIGE Stelle, an der
|
||||
Header geschrieben werden: jeder Feldwert wird vor dem Schreiben hart
|
||||
gegen CR/LF/Steuerzeichen geprüft, `Message.Build()` nutzt
|
||||
ausschließlich diese API — keine freie Stringkonkatenation von
|
||||
From/To/Subject (behebt den bekannten archivmail-Fehler #1,
|
||||
Header-Injection durch ungeprüfte Konkatenation). `Sender.Send`
|
||||
überträgt die gebaute Nachricht per echtem `net/smtp`-Client
|
||||
(Standardbibliothek, reale TCP-Verbindung) über HELO/MAIL FROM/RCPT
|
||||
TO/DATA. Keine Zugangsdaten im Code — die Zieladresse wird als
|
||||
Parameter/Umgebungsvariable vom Aufrufer bereitgestellt.
|
||||
|
||||
## Pflichtprüfung 1: Steuerzeichen/CRLF in Betreff und Anzeigenamen — kein Header-Bruch möglich
|
||||
|
||||
`TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName`
|
||||
(`mailer/mailer_test.go`), vier Fälle: CRLF im Betreff (versuchte
|
||||
Bcc-Injection), CRLF im Anzeigenamen des Absenders, nackter LF ohne CR,
|
||||
Steuerzeichen NUL im Betreff — `Message.Build()` liefert in allen vier
|
||||
Fällen einen Fehler, KEINE gebaute Nachricht. Ergänzend
|
||||
`TestHeaderWriter_AcceptsCleanValues`: normale Werte (inkl. Umlaute)
|
||||
werden nicht fälschlich abgelehnt.
|
||||
|
||||
Ergebnis: **BESTANDEN**.
|
||||
|
||||
## Pflichtprüfung 2: automatisierter Test sendet Testmail über Mailpit/MailHog, prüft Header-Integrität
|
||||
|
||||
**Abweichung von der wörtlichen Ticketvorgabe, dokumentiert:** Mailpit
|
||||
und MailHog sind auf diesem Rechner NICHT installiert — Projektregel
|
||||
verbietet das Nachinstallieren zusätzlicher Toolchains/Dienste
|
||||
(kein Docker verfügbar, keine Systempaketinstallation). Als echter
|
||||
Ersatz — kein Mock, kein fabriziertes Transkript, dieselbe Konvention
|
||||
wie die manuellen Client-Tests aus ING-01/ING-02 — läuft
|
||||
`TestSender_SendRealMessageOverSMTP_HeaderIntegrity`
|
||||
(`mailer/mailer_test.go`) gegen den in dieser Kachel gebauten, echten
|
||||
`mail/internal/smtp`-Server: realer TCP-Listener, echter
|
||||
`net/smtp`-Standardbibliotheks-Client, reale HELO/MAIL FROM/RCPT
|
||||
TO/DATA-Sequenz über das Netzwerk. Geprüft wird:
|
||||
|
||||
- Envelope (`From`/`To`) kommt beim Server unverändert an.
|
||||
- From-, To-, Subject- und ein zusätzlicher Header (`X-NEXARCH-Test`)
|
||||
kommen byte-identisch als eigene Headerzeilen an.
|
||||
- Genau eine Leerzeile trennt Header von Body (`\r\n\r\n`), Body-Text
|
||||
vollständig und unverändert.
|
||||
|
||||
Ergebnis: **BESTANDEN** — Header-Integrität über einen echten
|
||||
Ende-zu-Ende-SMTP-Dialog bestätigt.
|
||||
|
||||
## Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne Verbindungsleck
|
||||
|
||||
`TestServer_ConcurrentConnectionsNoLeak` (`smtp/smtp_test.go`): 50
|
||||
parallele reale TCP-Verbindungen, jede vollständige
|
||||
EHLO/MAIL/RCPT/DATA/QUIT-Sequenz. Alle 50 Nachrichten kommen beim Sink
|
||||
an. `runtime.NumGoroutine()` vor und nach dem Lasttest verglichen (mit
|
||||
Toleranz für Laufzeit-Jitter und Aufräumzeit).
|
||||
|
||||
Ergebnis: **BESTANDEN** — Goroutinezahl kehrt auf den Ausgangswert
|
||||
zurück, kein Verbindungs-/Ressourcenleck.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
1. **SMTP-Annahme validiert Envelope und Nachrichtengröße vor der
|
||||
Annahme**: `TestSession_EnvelopeMustBeBuiltBeforeData` (schrittweise
|
||||
Envelope-Prüfung, `503` bei übersprungenen Schritten) und
|
||||
`TestData_MessageSizeCheckedBeforeAcceptance` (Überschreitung der
|
||||
konfigurierten Höchstgröße führt zu `552`, Sink bekommt die
|
||||
Nachricht NICHT, Session danach weiter funktionsfähig).
|
||||
2. **Mailer erzeugt Header ausschließlich über strukturierte
|
||||
Writer-API, keine freie Stringkonkatenation**: `header.go`
|
||||
(`headerWriter.WriteField`) ist der einzige Ort, an dem
|
||||
`Message.Build()` Header schreibt; durch Pflichtprüfung 1 belegt.
|
||||
3. **Ungültige Empfängerdaten führen zu sauberer SMTP-Fehlermeldung
|
||||
statt Absturz**: `TestRcptTo_InvalidRecipientCleanError` und
|
||||
`TestMailFrom_InvalidSenderCleanError` — `553` bei ungültiger
|
||||
Adresse, Verbindung bleibt danach nutzbar.
|
||||
|
||||
## 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. neuen internal/smtp und internal/mailer
|
||||
```
|
||||
|
||||
Keine Regression in den bestehenden ~26 Paketen.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
ING-03 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
|
||||
Nachweisen. Pflichtprüfung 2 wurde mangels installierbarem
|
||||
Mailpit/MailHog gegen den eigenen, in dieser Kachel gebauten
|
||||
SMTP-Server durchgeführt (funktional gleichwertig: echter SMTP-Dialog,
|
||||
kein Mock) — siehe Abschnitt oben. Freigeschaltet: ING-06, ING-08,
|
||||
ING-09, ING-10, QA-04, QA-07.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package mailer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// headerWriter schreibt E-Mail-Header ausschließlich über diese
|
||||
// strukturierte API (Akzeptanzkriterium 2) — nie über freie
|
||||
// Stringkonkatenation von Feldname und -wert. Jeder Feldwert wird vor
|
||||
// dem Schreiben hart gegen CRLF/Steuerzeichen geprüft: bekannter Fehler
|
||||
// aus archivmail (known-issues-archivmail.md #1) — From/To/Subject
|
||||
// wurden dort per Konkatenation ohne Prüfung zusammengebaut, was
|
||||
// Header-Injection über eingeschleuste Zeilenumbrüche erlaubte.
|
||||
type headerWriter struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// WriteField validiert value und hängt bei Erfolg "name: value\r\n" an.
|
||||
// Ein Fehler lässt buf unverändert.
|
||||
func (h *headerWriter) WriteField(name, value string) error {
|
||||
if err := validateHeaderValue(value); err != nil {
|
||||
return fmt.Errorf("mailer: feld %q: %w", name, err)
|
||||
}
|
||||
h.buf = append(h.buf, name...)
|
||||
h.buf = append(h.buf, ':', ' ')
|
||||
h.buf = append(h.buf, value...)
|
||||
h.buf = append(h.buf, '\r', '\n')
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *headerWriter) Bytes() []byte { return h.buf }
|
||||
|
||||
// validateHeaderValue lehnt Steuerzeichen ab, insbesondere CR/LF, mit
|
||||
// denen sich sonst zusätzliche Header oder ein vorzeitiges Body-Ende
|
||||
// einschleusen ließen (Header-Injection).
|
||||
func validateHeaderValue(value string) error {
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '\r' || r == '\n':
|
||||
return fmt.Errorf("enthält zeilenumbruch (header-injection verhindert)")
|
||||
case r == '\t':
|
||||
// Tabs sind in gefalteten Headerwerten zulässig.
|
||||
case r < 0x20:
|
||||
return fmt.Errorf("enthält steuerzeichen 0x%02x", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package mailer implementiert ING-03s Mailer-Komponente für ausgehende
|
||||
// Benachrichtigungen/Berichte: Nachrichtenaufbau ausschließlich über
|
||||
// eine strukturierte Header-Writer-API (header.go, Akzeptanzkriterium
|
||||
// 2) sowie Versand per echtem SMTP-Dialog.
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message ist eine ausgehende Nachricht.
|
||||
type Message struct {
|
||||
From string
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
// ExtraHeaders sind zusätzliche Headerfelder (Name -> Wert), z. B.
|
||||
// "Reply-To". Werden nach den festen Feldern in Map-Iterationsreihenfolge
|
||||
// geschrieben (Reihenfolge zwischen ihnen ist nicht garantiert).
|
||||
ExtraHeaders map[string]string
|
||||
}
|
||||
|
||||
// Build erzeugt die vollständige RFC-5322-Nachricht (Header + Leerzeile
|
||||
// + Body) ausschließlich über headerWriter (Akzeptanzkriterium 2: keine
|
||||
// freie Stringkonkatenation von Header-Feldern).
|
||||
func (m Message) Build() ([]byte, error) {
|
||||
hw := &headerWriter{}
|
||||
if err := hw.WriteField("From", m.From); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hw.WriteField("To", strings.Join(m.To, ", ")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hw.WriteField("Subject", m.Subject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, value := range m.ExtraHeaders {
|
||||
if err := hw.WriteField(name, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.Write(hw.Bytes())
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(m.Body)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Sender versendet fertig gebaute Nachrichten per echtem SMTP-Dialog
|
||||
// (HELO/MAIL FROM/RCPT TO/DATA).
|
||||
type Sender struct {
|
||||
// Addr ist die SMTP-Serveradresse (host:port). Ausschließlich über
|
||||
// Umgebungsvariable durch den Aufrufer bereitzustellen — keine
|
||||
// Zugangsdaten/Verbindungszeichenfolgen im Code dieses Pakets.
|
||||
Addr string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewSender(addr string) *Sender {
|
||||
return &Sender{Addr: addr, Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
// Send baut die Nachricht (Akzeptanzkriterium 2) und überträgt sie per
|
||||
// echtem SMTP-Client (stdlib net/smtp, reale TCP-Verbindung) an s.Addr.
|
||||
// Ungültige Empfängerdaten werden vom SMTP-Server sauber zurückgewiesen
|
||||
// (Akzeptanzkriterium 3) und hier als Fehler durchgereicht, kein Absturz.
|
||||
func (s *Sender) Send(ctx context.Context, m Message) error {
|
||||
if len(m.To) == 0 {
|
||||
return fmt.Errorf("mailer: kein empfänger")
|
||||
}
|
||||
raw, err := m.Build()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: nachricht aufbauen: %w", err)
|
||||
}
|
||||
|
||||
dialer := net.Dialer{Timeout: s.Timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", s.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: verbindung zu %s: %w", s.Addr, err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, hostOnly(s.Addr))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: smtp-client: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
if err := client.Hello("nexarch-mail"); err != nil {
|
||||
return fmt.Errorf("mailer: HELO: %w", err)
|
||||
}
|
||||
if err := client.Mail(m.From); err != nil {
|
||||
return fmt.Errorf("mailer: MAIL FROM: %w", err)
|
||||
}
|
||||
for _, rcpt := range m.To {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("mailer: RCPT TO %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: DATA: %w", err)
|
||||
}
|
||||
if _, err := wc.Write(raw); err != nil {
|
||||
return fmt.Errorf("mailer: nachricht senden: %w", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
return fmt.Errorf("mailer: nachricht abschließen: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/smtp"
|
||||
)
|
||||
|
||||
// TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName
|
||||
// ist die geforderte Pflichtprüfung 1 (ING-03): Steuerzeichen/CRLF in
|
||||
// Betreff und Anzeigenamen schlagen fehl statt einen Header-Bruch zu
|
||||
// erzeugen (bekannter Fehler aus archivmail, known-issues-archivmail.md
|
||||
// #1).
|
||||
func TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg Message
|
||||
}{
|
||||
{
|
||||
name: "CRLF im Betreff schleust zusätzlichen Header ein",
|
||||
msg: Message{
|
||||
From: "absender@example.com",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "Harmlos\r\nBcc: angreifer@example.com",
|
||||
Body: "Hallo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CRLF im Anzeigenamen des Absenders",
|
||||
msg: Message{
|
||||
From: "\"Böser Name\r\nX-Injected: true\" <absender@example.com>",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "Normal",
|
||||
Body: "Hallo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nackter LF ohne CR",
|
||||
msg: Message{
|
||||
From: "absender@example.com",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "Betreff\nBcc: angreifer@example.com",
|
||||
Body: "Hallo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Steuerzeichen NUL im Betreff",
|
||||
msg: Message{
|
||||
From: "absender@example.com",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "Betreff\x00Ende",
|
||||
Body: "Hallo",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := tc.msg.Build()
|
||||
if err == nil {
|
||||
t.Fatalf("erwartete Fehler (Header-Injection verhindert), habe erfolgreich gebaute Nachricht: %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderWriter_AcceptsCleanValues stellt sicher, dass normale Werte
|
||||
// nicht fälschlich abgelehnt werden.
|
||||
func TestHeaderWriter_AcceptsCleanValues(t *testing.T) {
|
||||
msg := Message{
|
||||
From: "Absender Name <absender@example.com>",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "Ganz normaler Betreff mit Umlauten äöü",
|
||||
Body: "Hallo Welt",
|
||||
}
|
||||
raw, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter fehler: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "Subject: Ganz normaler Betreff mit Umlauten äöü\r\n") {
|
||||
t.Fatalf("subject-header fehlt oder falsch formatiert: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink zeichnet die zuletzt vom SMTP-Server angenommene
|
||||
// Nachricht auf.
|
||||
type captureSink struct {
|
||||
envelope smtp.Envelope
|
||||
raw []byte
|
||||
got chan struct{}
|
||||
}
|
||||
|
||||
func newCaptureSink() *captureSink {
|
||||
return &captureSink{got: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (c *captureSink) Accept(_ context.Context, envelope smtp.Envelope, raw []byte) error {
|
||||
c.envelope = envelope
|
||||
c.raw = raw
|
||||
c.got <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSender_SendRealMessageOverSMTP_HeaderIntegrity ist die geforderte
|
||||
// Pflichtprüfung 2 (ING-03): automatisierter Test sendet eine Testmail
|
||||
// über einen echten SMTP-Dialog und prüft Header-Integrität.
|
||||
//
|
||||
// Mailpit/MailHog sind auf diesem Rechner NICHT installiert (Projektregel:
|
||||
// keine zusätzlichen Toolchains/Dienste installieren). Als echter
|
||||
// Ersatz — kein Mock, kein fabriziertes Transkript — läuft dieser Test
|
||||
// gegen den in DIESER Kachel gebauten, echten mail/internal/smtp-Server:
|
||||
// realer TCP-Dialog, realer stdlib-net/smtp-Client, reale
|
||||
// HELO/MAIL FROM/RCPT TO/DATA-Sequenz. Der Aufbau ist funktional
|
||||
// identisch zu einem Test gegen Mailpit — geprüft wird die
|
||||
// Header-Integrität END-ZU-ENDE über echtes SMTP, nicht die
|
||||
// Mailpit-Weboberfläche.
|
||||
func TestSender_SendRealMessageOverSMTP_HeaderIntegrity(t *testing.T) {
|
||||
sink := newCaptureSink()
|
||||
srv := smtp.NewServer(sink)
|
||||
|
||||
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)
|
||||
}()
|
||||
defer func() {
|
||||
cancel()
|
||||
<-done
|
||||
}()
|
||||
|
||||
sender := NewSender(listener.Addr().String())
|
||||
msg := Message{
|
||||
From: "absender@example.com",
|
||||
To: []string{"empfaenger@example.com"},
|
||||
Subject: "ING-03 Testmail über echten SMTP-Dialog",
|
||||
Body: "Dies ist der Nachrichtentext.\r\n",
|
||||
ExtraHeaders: map[string]string{
|
||||
"X-NEXARCH-Test": "ING-03",
|
||||
},
|
||||
}
|
||||
|
||||
sendCtx, sendCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer sendCancel()
|
||||
if err := sender.Send(sendCtx, msg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-sink.got:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("smtp-server hat die nachricht nicht innerhalb der frist angenommen")
|
||||
}
|
||||
|
||||
if sink.envelope.From != msg.From {
|
||||
t.Fatalf("envelope-from stimmt nicht: habe %q, will %q", sink.envelope.From, msg.From)
|
||||
}
|
||||
if len(sink.envelope.To) != 1 || sink.envelope.To[0] != msg.To[0] {
|
||||
t.Fatalf("envelope-to stimmt nicht: habe %v, will %v", sink.envelope.To, msg.To)
|
||||
}
|
||||
|
||||
rawText := string(sink.raw)
|
||||
wantHeaders := []string{
|
||||
"From: " + msg.From + "\r\n",
|
||||
"To: " + msg.To[0] + "\r\n",
|
||||
"Subject: " + msg.Subject + "\r\n",
|
||||
"X-NEXARCH-Test: ING-03\r\n",
|
||||
}
|
||||
for _, want := range wantHeaders {
|
||||
if !strings.Contains(rawText, want) {
|
||||
t.Fatalf("header-integrität verletzt: erwartete zeile %q nicht in empfangener nachricht:\n%s", want, rawText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rawText, "Dies ist der Nachrichtentext.") {
|
||||
t.Fatalf("body fehlt oder beschädigt in empfangener nachricht:\n%s", rawText)
|
||||
}
|
||||
// Header und Body müssen durch genau eine Leerzeile getrennt sein
|
||||
// (RFC 5322) — kein Header-Bruch, keine verschmolzenen Zeilen.
|
||||
headerEnd := strings.Index(rawText, "\r\n\r\n")
|
||||
if headerEnd < 0 {
|
||||
t.Fatalf("keine header/body-trennzeile gefunden:\n%s", rawText)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
func (s *Session) handleHelo(verb, arg string) bool {
|
||||
if strings.TrimSpace(arg) == "" {
|
||||
return s.reply(501, verb+" requires a domain/address") == nil
|
||||
}
|
||||
// HELO/EHLO setzt den Envelope zurück, falls bereits einer im
|
||||
// Aufbau war (RFC 5321 §4.1.1.1).
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
|
||||
if verb == "EHLO" {
|
||||
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 {
|
||||
if s.state == Greeting {
|
||||
return s.reply(503, "send HELO/EHLO first") == nil
|
||||
}
|
||||
addr, err := parseMailAddressArg(arg, "FROM")
|
||||
if err != nil {
|
||||
return s.reply(501, "invalid MAIL FROM syntax") == nil
|
||||
}
|
||||
if err := validateAddress(addr); err != nil {
|
||||
// Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere
|
||||
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
||||
return s.reply(553, "invalid sender address") == nil
|
||||
}
|
||||
s.from = addr
|
||||
s.to = nil
|
||||
s.state = MailFromSet
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
||||
// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO
|
||||
// sind erlaubt.
|
||||
func (s *Session) handleRcptTo(arg string) bool {
|
||||
if s.state != MailFromSet && s.state != RcptToSet {
|
||||
return s.reply(503, "send MAIL FROM first") == nil
|
||||
}
|
||||
addr, err := parseMailAddressArg(arg, "TO")
|
||||
if err != nil {
|
||||
return s.reply(501, "invalid RCPT TO syntax") == nil
|
||||
}
|
||||
if err := validateAddress(addr); err != nil {
|
||||
// Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere
|
||||
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
||||
return s.reply(553, "invalid recipient address") == nil
|
||||
}
|
||||
s.to = append(s.to, addr)
|
||||
s.state = RcptToSet
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
func (s *Session) handleRset() bool {
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
if s.state != Greeting {
|
||||
s.state = Ready
|
||||
}
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
// handleData verlangt einen vollständig aufgebauten und validierten
|
||||
// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden
|
||||
// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur
|
||||
// Abschlusszeile ".".
|
||||
func (s *Session) handleData(ctx context.Context) bool {
|
||||
if s.state != RcptToSet {
|
||||
return s.reply(503, "send MAIL FROM/RCPT TO first") == nil
|
||||
}
|
||||
if err := s.reply(354, "Start mail input; end with <CRLF>.<CRLF>"); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for {
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if line == "." {
|
||||
break
|
||||
}
|
||||
// Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile,
|
||||
// die mit "." beginnt, verliert genau diesen ersten Punkt.
|
||||
line = strings.TrimPrefix(line, ".")
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
if int64(buf.Len()) > s.maxMessageBytes {
|
||||
// Akzeptanzkriterium 1: Nachrichtengröße wird VOR der
|
||||
// endgültigen Annahme geprüft — sauberer Fehlercode statt
|
||||
// unbegrenztem Pufferwachstum.
|
||||
_ = s.drainUntilDot()
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
return s.reply(552, "message size exceeds fixed maximum message size") == nil
|
||||
}
|
||||
}
|
||||
|
||||
envelope := Envelope{From: s.from, To: s.to}
|
||||
raw := buf.Bytes()
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
|
||||
if s.sink != nil {
|
||||
if err := s.sink.Accept(ctx, envelope, raw); err != nil {
|
||||
return s.reply(451, "unable to accept message, try again later") == nil
|
||||
}
|
||||
}
|
||||
return s.reply(250, "OK: message accepted") == nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package smtp implementiert ING-03s SMTP-Server (RFC 5321) für
|
||||
// eingehende Mails: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, QUIT.
|
||||
// Bewusste Neuimplementierung nach NEXARCH-Techstack, gleiche Konvention
|
||||
// wie mail/internal/imap und mail/internal/pop3 — eigene Session je
|
||||
// Verbindung in eigener Goroutine, schmale Sink-Schnittstelle statt
|
||||
// geteilter Typen über Paketgrenzen hinweg.
|
||||
package smtp
|
||||
|
||||
import "context"
|
||||
|
||||
// Envelope ist der SMTP-Umschlag einer eingehenden Nachricht, wie er
|
||||
// vor der DATA-Annahme validiert wurde (Akzeptanzkriterium 1).
|
||||
type Envelope struct {
|
||||
From string
|
||||
To []string
|
||||
}
|
||||
|
||||
// MessageSink nimmt eine vollständig empfangene, dot-entstuffte
|
||||
// Nachricht entgegen — Speicherung/Weiterverarbeitung ist Sache
|
||||
// anderer Kacheln.
|
||||
type MessageSink interface {
|
||||
Accept(ctx context.Context, envelope Envelope, raw []byte) error
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseCommand zerlegt eine Kommandozeile in Verb (großgeschrieben) und
|
||||
// restliches Argument.
|
||||
func parseCommand(line string) (verb, arg string) {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
|
||||
verb = strings.ToUpper(parts[0])
|
||||
if len(parts) == 2 {
|
||||
arg = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return verb, arg
|
||||
}
|
||||
|
||||
// parseMailAddressArg extrahiert die Adresse aus "FROM:<addr>" bzw.
|
||||
// "TO:<addr>" (RFC 5321 §4.1.1.2/4.1.1.3). SMTP-Parameter wie SIZE=...
|
||||
// werden für diese kleinste Lösung ignoriert.
|
||||
func parseMailAddressArg(arg, keyword string) (string, error) {
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
prefix := keyword + ":"
|
||||
if !strings.HasPrefix(upper, prefix) {
|
||||
return "", fmt.Errorf("smtp: erwartete %q am anfang von %q", prefix, arg)
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[len(prefix):])
|
||||
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
|
||||
rest = rest[:sp]
|
||||
}
|
||||
rest = strings.TrimPrefix(rest, "<")
|
||||
rest = strings.TrimSuffix(rest, ">")
|
||||
if rest == "" {
|
||||
return "", fmt.Errorf("smtp: leere adresse")
|
||||
}
|
||||
return rest, nil
|
||||
}
|
||||
|
||||
// validateAddress prüft eine E-Mail-Adresse defensiv gegen
|
||||
// Steuerzeichen und offensichtlich falsche Form (Akzeptanzkriterium 3:
|
||||
// ungültige Empfänger-/Absenderdaten führen zu sauberer Fehlermeldung
|
||||
// statt Absturz).
|
||||
func validateAddress(addr string) error {
|
||||
for _, r := range addr {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("smtp: steuerzeichen in adresse")
|
||||
}
|
||||
}
|
||||
at := strings.IndexByte(addr, '@')
|
||||
if at <= 0 || at == len(addr)-1 {
|
||||
return fmt.Errorf("smtp: ungültige adresse %q", addr)
|
||||
}
|
||||
if strings.IndexByte(addr[at+1:], '@') >= 0 {
|
||||
return fmt.Errorf("smtp: ungültige adresse %q", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package smtp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// reply schreibt eine einzeilige SMTP-Antwort "code text\r\n".
|
||||
func (s *Session) reply(code int, text string) error {
|
||||
if _, err := fmt.Fprintf(s.writer, "%d %s\r\n", code, text); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
|
||||
// replyMultiline schreibt eine mehrzeilige SMTP-Antwort (z. B. EHLO-
|
||||
// Capability-Liste): alle Zeilen außer der letzten mit "-" statt " "
|
||||
// nach dem Code (RFC 5321 §4.2.1).
|
||||
func (s *Session) replyMultiline(code int, lines []string) error {
|
||||
for i, line := range lines {
|
||||
sep := "-"
|
||||
if i == len(lines)-1 {
|
||||
sep = " "
|
||||
}
|
||||
if _, err := fmt.Fprintf(s.writer, "%d%s%s\r\n", code, sep, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// defaultMaxMessageBytes ist die Standard-Höchstgröße einer
|
||||
// angenommenen Nachricht (Akzeptanzkriterium 1).
|
||||
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. 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 {
|
||||
return NewServerWithMaxMessageBytes(sink, defaultMaxMessageBytes)
|
||||
}
|
||||
|
||||
// NewServerWithMaxMessageBytes erlaubt eine abweichende
|
||||
// Nachrichten-Höchstgröße, z. B. für Tests.
|
||||
func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Server {
|
||||
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() {
|
||||
<-ctx.Done()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
|
||||
}
|
||||
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
|
||||
// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt
|
||||
// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und
|
||||
// mail/internal/pop3).
|
||||
const maxCommandLineBytes = 8192
|
||||
|
||||
// Session ist eine einzelne SMTP-Verbindung mit eigener
|
||||
// Zustandsmaschine (Akzeptanzkriterium 1).
|
||||
type Session struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
writer *bufio.Writer
|
||||
sink MessageSink
|
||||
|
||||
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, 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,
|
||||
}
|
||||
}
|
||||
|
||||
// State liefert den aktuellen Sitzungszustand (für Tests).
|
||||
func (s *Session) State() State { return s.state }
|
||||
|
||||
// Serve führt die Sitzung bis QUIT oder Verbindungsende aus.
|
||||
func (s *Session) Serve(ctx context.Context) {
|
||||
defer func() { _ = s.conn.Close() }()
|
||||
|
||||
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
verb, arg := parseCommand(line)
|
||||
if !s.dispatch(ctx, verb, arg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) readLine() (string, error) {
|
||||
line, err := s.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) && line != "" {
|
||||
return strings.TrimRight(line, "\r"), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
// dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung
|
||||
// beenden (QUIT abgeschlossen oder nicht behebbarer Schreibfehler).
|
||||
func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
|
||||
switch verb {
|
||||
case "HELO", "EHLO":
|
||||
return s.handleHelo(verb, arg)
|
||||
case "MAIL":
|
||||
return s.handleMailFrom(arg)
|
||||
case "RCPT":
|
||||
return s.handleRcptTo(arg)
|
||||
case "DATA":
|
||||
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":
|
||||
_ = s.reply(221, "Bye")
|
||||
return false
|
||||
default:
|
||||
return s.reply(500, "Command not recognized") == nil
|
||||
}
|
||||
}
|
||||
|
||||
// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur
|
||||
// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen
|
||||
// Größenüberschreitung abgelehnten Nachricht synchron, ohne den
|
||||
// verworfenen Rest unbegrenzt im Speicher zu halten.
|
||||
func (s *Session) drainUntilDot() error {
|
||||
for {
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if line == "." {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSink zeichnet angenommene Nachrichten im Prozessspeicher auf.
|
||||
type fakeSink struct {
|
||||
mu sync.Mutex
|
||||
accepted []acceptedMessage
|
||||
fail bool
|
||||
}
|
||||
|
||||
type acceptedMessage struct {
|
||||
envelope Envelope
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func (f *fakeSink) Accept(_ context.Context, envelope Envelope, raw []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.fail {
|
||||
return errFakeSinkRejects
|
||||
}
|
||||
cp := make([]byte, len(raw))
|
||||
copy(cp, raw)
|
||||
f.accepted = append(f.accepted, acceptedMessage{envelope: envelope, raw: cp})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSink) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.accepted)
|
||||
}
|
||||
|
||||
type sinkError string
|
||||
|
||||
func (e sinkError) Error() string { return string(e) }
|
||||
|
||||
const errFakeSinkRejects sinkError = "fake sink lehnt ab"
|
||||
|
||||
func startTestServer(t *testing.T, sink MessageSink, maxMessageBytes int64) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
srv := NewServerWithMaxMessageBytes(sink, maxMessageBytes)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
type smtpClient struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func dial(t *testing.T, addr string) *smtpClient {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
|
||||
c.readLine(t) // 220 Begrüßung
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *smtpClient) readLine(t *testing.T) string {
|
||||
t.Helper()
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n")
|
||||
}
|
||||
|
||||
func (c *smtpClient) send(t *testing.T, cmd string) string {
|
||||
t.Helper()
|
||||
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
|
||||
t.Fatalf("kommando senden: %v", err)
|
||||
}
|
||||
return c.readLine(t)
|
||||
}
|
||||
|
||||
func (c *smtpClient) close() { _ = c.conn.Close() }
|
||||
|
||||
func code(line string) string {
|
||||
if len(line) < 3 {
|
||||
return line
|
||||
}
|
||||
return line[:3]
|
||||
}
|
||||
|
||||
// TestSession_EnvelopeMustBeBuiltBeforeData ist die geforderte
|
||||
// Zustandsmaschinen-Abdeckung für Akzeptanzkriterium 1: Envelope
|
||||
// (HELO -> MAIL FROM -> RCPT TO) wird SCHRITTWEISE validiert, DATA ist
|
||||
// erst nach vollständigem, gültigem Envelope erlaubt.
|
||||
func TestSession_EnvelopeMustBeBuiltBeforeData(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
// MAIL FROM vor HELO -> 503.
|
||||
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für MAIL FROM vor HELO, habe: %s", resp)
|
||||
}
|
||||
|
||||
if resp := c.send(t, "EHLO client.example.com"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für EHLO, habe: %s", resp)
|
||||
}
|
||||
// Mehrzeilige EHLO-Antwort vollständig lesen.
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// RCPT TO vor MAIL FROM -> 503.
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für RCPT TO vor MAIL FROM, habe: %s", resp)
|
||||
}
|
||||
|
||||
// DATA vor RCPT TO -> 503.
|
||||
if resp := c.send(t, "DATA"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für DATA ohne RCPT TO, habe: %s", resp)
|
||||
}
|
||||
|
||||
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für MAIL FROM, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für RCPT TO, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "DATA"); code(resp) != "354" {
|
||||
t.Fatalf("erwartete 354 für DATA nach vollständigem Envelope, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "Subject: test\r\n\r\nHallo\r\n."); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 nach abgeschlossener DATA, habe: %s", resp)
|
||||
}
|
||||
|
||||
if sink.count() != 1 {
|
||||
t.Fatalf("erwartete 1 angenommene nachricht, habe %d", sink.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestData_MessageSizeCheckedBeforeAcceptance ist die geforderte
|
||||
// Pflichtprüfung für Akzeptanzkriterium 1 (Größenanteil): eine
|
||||
// Nachricht über der konfigurierten Höchstgröße wird sauber
|
||||
// zurückgewiesen, der Sink bekommt sie NICHT.
|
||||
func TestData_MessageSizeCheckedBeforeAcceptance(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
const tinyLimit = 32 // Bytes
|
||||
addr, stop := startTestServer(t, sink, tinyLimit)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
c.send(t, "RCPT TO:<b@example.com>")
|
||||
if resp := c.send(t, "DATA"); code(resp) != "354" {
|
||||
t.Fatalf("erwartete 354, habe: %s", resp)
|
||||
}
|
||||
|
||||
// Die überlange Zeile überschreitet das Limit bereits selbst — der
|
||||
// Server antwortet SOFORT mit 552, OHNE auf die Abschlusszeile "."
|
||||
// zu warten (drainUntilDot liest sie erst danach weg, damit das
|
||||
// Protokoll synchron bleibt). Deshalb hier NICHT auf eine Antwort
|
||||
// zur ersten Zeile warten, sondern erst die Abschlusszeile senden
|
||||
// und dann einmal lesen.
|
||||
longBody := strings.Repeat("x", 200)
|
||||
if _, err := c.conn.Write([]byte(longBody + "\r\n")); err != nil {
|
||||
t.Fatalf("kommando senden: %v", err)
|
||||
}
|
||||
resp := c.send(t, ".")
|
||||
if code(resp) != "552" {
|
||||
t.Fatalf("erwartete 552 (nachricht zu groß), habe: %s", resp)
|
||||
}
|
||||
|
||||
if sink.count() != 0 {
|
||||
t.Fatalf("sink hätte die zu große nachricht nicht bekommen dürfen, habe %d", sink.count())
|
||||
}
|
||||
|
||||
// Verbindung muss danach weiter benutzbar sein (kein Absturz/Hänger).
|
||||
if resp := c.send(t, "NOOP"); code(resp) != "250" {
|
||||
t.Fatalf("session nach größenfehler nicht mehr funktionsfähig: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcptTo_InvalidRecipientCleanError ist die geforderte
|
||||
// Akzeptanzkriterium-3-Prüfung: ungültige Empfängerdaten führen zu
|
||||
// sauberer SMTP-Fehlermeldung statt Absturz.
|
||||
func TestRcptTo_InvalidRecipientCleanError(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
|
||||
if resp := c.send(t, "RCPT TO:<keine-gueltige-adresse>"); code(resp) != "553" {
|
||||
t.Fatalf("erwartete 553 für ungültigen empfänger, habe: %s", resp)
|
||||
}
|
||||
// Verbindung bleibt nutzbar — kein Absturz, kein Verbindungsabbruch.
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für gültigen empfänger nach vorherigem fehler, habe: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailFrom_InvalidSenderCleanError deckt Akzeptanzkriterium 3 auch
|
||||
// für den Absender ab.
|
||||
func TestMailFrom_InvalidSenderCleanError(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if resp := c.send(t, "MAIL FROM:<keine-gueltige-adresse>"); code(resp) != "553" {
|
||||
t.Fatalf("erwartete 553 für ungültigen absender, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "NOOP"); code(resp) != "250" {
|
||||
t.Fatalf("session nach ungültigem absender nicht mehr funktionsfähig: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ConcurrentConnectionsNoLeak ist die geforderte
|
||||
// Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne
|
||||
// Verbindungsleck.
|
||||
func TestServer_ConcurrentConnectionsNoLeak(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
|
||||
runtime.GC()
|
||||
baseline := runtime.NumGoroutine()
|
||||
|
||||
const concurrency = 50
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
|
||||
c.readLine(t)
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
c.send(t, "RCPT TO:<b@example.com>")
|
||||
c.send(t, "DATA")
|
||||
c.send(t, "Subject: last\r\n\r\nHallo\r\n.")
|
||||
c.send(t, "QUIT")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if sink.count() != concurrency {
|
||||
t.Fatalf("erwartete %d angenommene nachrichten, habe %d", concurrency, sink.count())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
runtime.GC()
|
||||
current := runtime.NumGoroutine()
|
||||
if current <= baseline+2 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("verbindungs-/goroutine-leck nach lasttest: baseline=%d, aktuell=%d", baseline, current)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package smtp
|
||||
|
||||
// State ist einer der vier SMTP-Sitzungszustände dieser Implementierung
|
||||
// (RFC 5321 §3.3), Akzeptanzkriterium 1: Envelope wird schrittweise vor
|
||||
// der DATA-Annahme aufgebaut und geprüft.
|
||||
type State int
|
||||
|
||||
const (
|
||||
Greeting State = iota // vor HELO/EHLO
|
||||
Ready // nach HELO/EHLO, bereit für MAIL FROM
|
||||
MailFromSet // nach gültigem MAIL FROM, wartet auf RCPT TO
|
||||
RcptToSet // mind. ein gültiges RCPT TO, DATA erlaubt
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case Greeting:
|
||||
return "GREETING"
|
||||
case Ready:
|
||||
return "READY"
|
||||
case MailFromSet:
|
||||
return "MAIL FROM SET"
|
||||
case RcptToSet:
|
||||
return "RCPT TO SET"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user