Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c892ed10a | ||
|
|
b22ab67bb2 |
@@ -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.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# ING-08 — Mailserver-Protokoll-Logging & Diagnose: Prüfprotokoll
|
||||||
|
|
||||||
|
Datum: 2026-09-01
|
||||||
|
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
|
||||||
|
Pakete: `mail/internal/protolog` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3`, `mail/internal/smtp`
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
Neues Paket `protolog` (`log/slog`, wie im Ticket vorgegeben) bündelt
|
||||||
|
die für alle drei Protokollserver gemeinsame Logging-Grundlage:
|
||||||
|
|
||||||
|
- `NewCorrelationID()` erzeugt eine zufällige, session-eindeutige ID.
|
||||||
|
- `SessionLogger` loggt strukturierte Ereignisse EINER Verbindung, mit
|
||||||
|
`correlation_id` und `protocol` als festen Feldern auf jedem Eintrag
|
||||||
|
(Akzeptanzkriterium 1). Ein `SessionLogger` mit `logger == nil` ist
|
||||||
|
sicher benutzbar und loggt nichts — Server ohne konfigurierten Logger
|
||||||
|
verhalten sich unverändert wie vor ING-08 (Rückwärtskompatibilität zu
|
||||||
|
ING-01..ING-07).
|
||||||
|
- `RedactCommandLine(verb, args)` liefert eine loggbare
|
||||||
|
Kommandodarstellung: bei sensiblen Verben (`PASS`, `LOGIN`, `AUTH`)
|
||||||
|
werden ALLE Argumente vollständig durch `[REDACTED]` ersetzt statt
|
||||||
|
einzeln geparst — verhindert, dass unerwartet platzierte
|
||||||
|
Zugangsdaten durchrutschen (Akzeptanzkriterium 2).
|
||||||
|
- `Reconstruct(r, correlationID)` (`diagnose.go`) ist das geforderte
|
||||||
|
Diagnosewerkzeug: liest zeilenweise JSON-Logs und liefert, in
|
||||||
|
Log-Reihenfolge, ausschließlich die Einträge einer Korrelations-ID
|
||||||
|
(Akzeptanzkriterium 3).
|
||||||
|
|
||||||
|
**Alle drei Sessions** (IMAP, POP3, SMTP) loggen jetzt:
|
||||||
|
`session_start` (mit `remote_addr`) beim Verbindungsaufbau, EIN
|
||||||
|
`command`-Ereignis pro empfangener Kommandozeile (Kommandoname +
|
||||||
|
via `RedactCommandLine` redigierte Argumente) und `session_end` per
|
||||||
|
`defer` — deckt die gesamte Verbindungsdauer ab (Akzeptanzkriterium 1).
|
||||||
|
Reader/Writer-Aufsetzung nach STARTTLS/STLS bleibt unverändert (ING-06);
|
||||||
|
der Logger wird unabhängig von TLS-Zustand weitergereicht.
|
||||||
|
|
||||||
|
**Nachrichteninhalte werden strukturell nie geloggt**: POP3 `RETR`
|
||||||
|
liefert Nachrichteninhalt nur in der SMTP-/POP3-Antwort, nicht als
|
||||||
|
Log-Attribut; SMTP-`DATA`-Body-Zeilen werden von einer eigenen
|
||||||
|
Leseschleife (`handleData`) konsumiert, die NICHT durch den
|
||||||
|
Kommando-Logpfad der `Serve`-Hauptschleife läuft — nur das Kommando
|
||||||
|
`DATA` selbst erscheint im Log, nie der Body (Akzeptanzkriterium 2).
|
||||||
|
|
||||||
|
Neue Konstruktoren `NewServerWithGuardTLSAndLogger` (IMAP/POP3) und
|
||||||
|
`NewServerWithMaxMessageBytesTLSAndLogger` (SMTP) — `logger` optional,
|
||||||
|
bestehende Konstruktoren (`NewServer`, `NewServerWithGuardConfig`,
|
||||||
|
`NewServerWithGuardAndTLSConfig` usw.) unverändert.
|
||||||
|
|
||||||
|
## Pflichtprüfung 1: Redaktion sensibler Felder in allen Log-Pfaden
|
||||||
|
|
||||||
|
Isoliert: `TestRedactCommandLine_HidesCredentials` und
|
||||||
|
`TestSessionLogger_EventNeverContainsRawMessage`
|
||||||
|
(`protolog/protolog_test.go`).
|
||||||
|
|
||||||
|
Gegen den ECHTEN, laufenden Server (nicht nur die protolog-Bausteine):
|
||||||
|
`TestProtolog_RedactsCredentialsInRealSessionLog` in `imap` (LOGIN mit
|
||||||
|
Klartextpasswort) und `pop3` (USER/PASS) — vollständige reale Session
|
||||||
|
über TCP, Logausgabe geprüft: kein Klartextpasswort, redigierter
|
||||||
|
Eintrag vorhanden. `TestProtolog_NeverLogsMessageBodyOrRedactsCredentials`
|
||||||
|
in `smtp`: reale Nachricht mit absichtlich eingebettetem
|
||||||
|
`Passwort=geheim123` im Betreff/Body per DATA übertragen — weder das
|
||||||
|
eingebettete Geheimnis noch der Nachrichtentext erscheinen im Log.
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN** in allen drei Protokollen.
|
||||||
|
|
||||||
|
## Pflichtprüfung 2: Stichprobe — eine komplette Session ist über die Korrelations-ID lückenlos rekonstruierbar
|
||||||
|
|
||||||
|
`TestProtolog_SessionFullyReconstructableByCorrelationID` in allen drei
|
||||||
|
Protokollpaketen: ZWEI vollständige, nacheinander über denselben Server
|
||||||
|
laufende Sessions werden in denselben Logstream geschrieben (Logs
|
||||||
|
mischen sich, wie im Betrieb). `protolog.Reconstruct` mit der
|
||||||
|
Korrelations-ID der ersten Session liefert exakt deren Einträge, in
|
||||||
|
korrekter Reihenfolge, beginnend mit `session_start` und endend mit
|
||||||
|
`session_end`, jeder Zwischeneintrag mit passender `correlation_id` —
|
||||||
|
keine Vermischung mit der zweiten Session. Zusätzlich
|
||||||
|
`TestReconstruct_ReturnsOnlyMatchingSessionInOrder`
|
||||||
|
(`protolog/protolog_test.go`) als isolierter Baustein-Test.
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN** in allen drei Protokollen — Stichprobe
|
||||||
|
tatsächlich gezogen und lückenlos rekonstruiert.
|
||||||
|
|
||||||
|
## Pflichtprüfung 3: Lasttest bestätigt, dass Logging die Durchsatzrate nicht relevant beeinträchtigt
|
||||||
|
|
||||||
|
`TestProtolog_LoggingDoesNotRelevantlyImpactThroughput` in allen drei
|
||||||
|
Protokollpaketen: 100 vollständige reale Sessions ohne Logger
|
||||||
|
(`logger == nil`, no-op) gegen 100 identische Sessions mit aktivem
|
||||||
|
JSON-Logger gemessen, jeweils über echte TCP-Verbindungen gegen den
|
||||||
|
laufenden Server. Ergebnis auf 192.168.1.131:
|
||||||
|
|
||||||
|
```
|
||||||
|
pop3: PASS (0.11s für 100 Sessions mit Logging, im Toleranzfaktor)
|
||||||
|
imap: PASS (0.10s für 100 Sessions mit Logging, im Toleranzfaktor)
|
||||||
|
smtp: PASS (0.11s für 100 Sessions mit Logging, im Toleranzfaktor)
|
||||||
|
```
|
||||||
|
|
||||||
|
Toleranzfaktor 3× + 5ms Grundrauschen, um Messschwankungen auf einem
|
||||||
|
geteilten Testhost abzufangen — Ziel ist der Ausschluss eines groben
|
||||||
|
Regressionsfaktors (z. B. unbuffered/synchrones I/O pro Byte), nicht
|
||||||
|
eine exakte Performance-Zusicherung.
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN** in allen drei Protokollen.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
1. **Jede Session erzeugt strukturierte Logs mit Korrelations-ID über
|
||||||
|
die gesamte Verbindungsdauer**: `session_start`/`command`
|
||||||
|
(mehrfach)/`session_end`, alle mit derselben `correlation_id` —
|
||||||
|
durch Pflichtprüfung 2 belegt.
|
||||||
|
2. **Zugangsdaten und Nachrichteninhalte erscheinen nie im Klartext im
|
||||||
|
Log**: durch Pflichtprüfung 1 belegt.
|
||||||
|
3. **Diagnosewerkzeug kann eine einzelne Session anhand der
|
||||||
|
Korrelations-ID vollständig nachvollziehen**: `protolog.Reconstruct`,
|
||||||
|
durch Pflichtprüfung 2 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/protolog
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine Regression in den bestehenden ~29 Paketen.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
ING-08 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
|
||||||
|
Nachweisen — in allen drei Protokollen (IMAP, POP3, SMTP) einzeln
|
||||||
|
geprüft. Freigeschaltet: QA-02.
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
package imap
|
package imap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleCapability ist in jedem Zustand erlaubt (RFC 3501 §6.1.1).
|
// handleCapability ist in jedem Zustand erlaubt (RFC 3501 §6.1.1).
|
||||||
func (s *Session) handleCapability(cmd command) bool {
|
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 false
|
||||||
}
|
}
|
||||||
return s.writeErr(cmd.Tag, "OK", "CAPABILITY completed")
|
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 {
|
if len(cmd.Args) != 2 {
|
||||||
return s.writeErr(cmd.Tag, "BAD", "LOGIN requires username and password")
|
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 {
|
if s.auth == nil {
|
||||||
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
|
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
|
||||||
}
|
}
|
||||||
@@ -154,6 +167,38 @@ func (s *Session) handleLogout(cmd command) bool {
|
|||||||
return false
|
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:
|
// parseSequenceSet unterstützt die für FETCH gebräuchlichsten Formen:
|
||||||
// eine einzelne Zahl ("1"), eine kommagetrennte Liste ("1,3,5") und einen
|
// eine einzelne Zahl ("1"), eine kommagetrennte Liste ("1,3,5") und einen
|
||||||
// Bereich mit "*" als offenem Ende ("1:*"), aufgelöst gegen maxSeq (die
|
// Bereich mit "*" als offenem Ende ("1:*"), aufgelöst gegen maxSeq (die
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package imap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startLoggedTestServer(t *testing.T, logger *slog.Logger) (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 := NewServerWithGuardTLSAndLogger(auth, store, protoguard.DefaultConfig(), nil, logger)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFullSession(t *testing.T, addr string) {
|
||||||
|
t.Helper()
|
||||||
|
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')
|
||||||
|
sendTaggedOn(t, conn, reader, "A1", "LOGIN alice geheim123")
|
||||||
|
sendTaggedOn(t, conn, reader, "A2", "SELECT INBOX")
|
||||||
|
sendTaggedOn(t, conn, reader, "A3", "LOGOUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_RedactsCredentialsInRealSessionLog ist die geforderte
|
||||||
|
// Pflichtprüfung 1 (ING-08) gegen den echten, laufenden IMAP-Server.
|
||||||
|
func TestProtolog_RedactsCredentialsInRealSessionLog(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addr, stop := startLoggedTestServer(t, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
if strings.Contains(logged, "geheim123") {
|
||||||
|
t.Fatalf("passwort im klartext im log gefunden:\n%s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, "LOGIN [REDACTED]") {
|
||||||
|
t.Fatalf("erwartete redigierten LOGIN-eintrag im log, habe:\n%s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_SessionFullyReconstructableByCorrelationID ist die
|
||||||
|
// geforderte Pflichtprüfung 2 (ING-08).
|
||||||
|
func TestProtolog_SessionFullyReconstructableByCorrelationID(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addr, stop := startLoggedTestServer(t, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
runFullSession(t, addr)
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
firstLine := strings.SplitN(buf.String(), "\n", 2)[0]
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(firstLine), &raw); err != nil {
|
||||||
|
t.Fatalf("erste logzeile parsen: %v", err)
|
||||||
|
}
|
||||||
|
firstID, _ := raw["correlation_id"].(string)
|
||||||
|
if firstID == "" {
|
||||||
|
t.Fatalf("keine correlation_id in erster logzeile: %s", firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := protolog.Reconstruct(bytes.NewReader(buf.Bytes()), firstID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconstruct: %v", err)
|
||||||
|
}
|
||||||
|
// session_start, 3 kommandos (LOGIN/SELECT/LOGOUT), session_end.
|
||||||
|
if len(entries) != 5 {
|
||||||
|
t.Fatalf("erwartete 5 lückenlose einträge für die session, habe %d: %+v", len(entries), entries)
|
||||||
|
}
|
||||||
|
if entries[0].Msg != "session_start" || entries[len(entries)-1].Msg != "session_end" {
|
||||||
|
t.Fatalf("session nicht lückenlos rekonstruierbar: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_LoggingDoesNotRelevantlyImpactThroughput ist die
|
||||||
|
// geforderte Pflichtprüfung 3 (ING-08).
|
||||||
|
func TestProtolog_LoggingDoesNotRelevantlyImpactThroughput(t *testing.T) {
|
||||||
|
const sessions = 100
|
||||||
|
|
||||||
|
addrOff, stopOff := startLoggedTestServer(t, nil)
|
||||||
|
startOff := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOff)
|
||||||
|
}
|
||||||
|
durationOff := time.Since(startOff)
|
||||||
|
stopOff()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addrOn, stopOn := startLoggedTestServer(t, logger)
|
||||||
|
startOn := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOn)
|
||||||
|
}
|
||||||
|
durationOn := time.Since(startOn)
|
||||||
|
stopOn()
|
||||||
|
|
||||||
|
if durationOn > 3*durationOff+5*time.Millisecond {
|
||||||
|
t.Fatalf("logging verlangsamt durchsatz relevant: ohne=%v, mit=%v", durationOff, durationOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,22 +2,27 @@ package imap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
|
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
|
||||||
// Goroutine (Akzeptanzkriterium 1). STARTTLS/TLS-Absicherung ist
|
// Goroutine (Akzeptanzkriterium 1). tlsConfig ist optional (ING-06):
|
||||||
// ausdrücklich Sache von ING-06, nicht dieser Kachel — Server hört per
|
// nil bedeutet Klartextbetrieb wie vor ING-06 (z. B. hinter einem
|
||||||
// Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die
|
// separaten TLS-Terminator oder in Tests); ist tlsConfig gesetzt,
|
||||||
// Schnittstelle dafür vor").
|
// verlangt LOGIN zwingend STARTTLS oder eine bereits per implizitem
|
||||||
|
// TLS-Listener verschlüsselte Verbindung (Akzeptanzkriterium 1).
|
||||||
type Server struct {
|
type Server struct {
|
||||||
auth Authenticator
|
auth Authenticator
|
||||||
store MailboxStore
|
store MailboxStore
|
||||||
guardCfg protoguard.Config
|
guardCfg protoguard.Config
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||||
@@ -31,6 +36,20 @@ func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg p
|
|||||||
return &Server{auth: auth, store: store, guardCfg: guardCfg}
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerWithGuardTLSAndLogger erlaubt zusätzlich strukturiertes
|
||||||
|
// Protokoll-Logging (ING-08). logger darf nil sein (Logging dann
|
||||||
|
// deaktiviert, Rückwärtskompatibilität zu ING-01..ING-07).
|
||||||
|
func NewServerWithGuardTLSAndLogger(auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger) *Server {
|
||||||
|
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
|
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
|
||||||
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
|
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
|
||||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||||
@@ -51,7 +70,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("imap: verbindung annehmen: %w", err)
|
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, srv.logger)
|
||||||
go session.Serve(ctx)
|
go session.Serve(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package imap
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser
|
// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser
|
||||||
@@ -36,12 +39,16 @@ type Session struct {
|
|||||||
auth Authenticator
|
auth Authenticator
|
||||||
store MailboxStore
|
store MailboxStore
|
||||||
guard *protoguard.Guard
|
guard *protoguard.Guard
|
||||||
|
tlsConfig *tls.Config // nil = kein TLS/STARTTLS angeboten (ING-06)
|
||||||
|
tlsActive bool
|
||||||
|
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
|
||||||
state State
|
state State
|
||||||
mailbox string // gewähltes Postfach im Zustand Selected
|
mailbox string // gewähltes Postfach im Zustand Selected
|
||||||
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
|
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, logger *slog.Logger) *Session {
|
||||||
|
_, alreadyTLS := conn.(*tls.Conn)
|
||||||
return &Session{
|
return &Session{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||||
@@ -49,6 +56,9 @@ func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg
|
|||||||
auth: auth,
|
auth: auth,
|
||||||
store: store,
|
store: store,
|
||||||
guard: protoguard.New(guardCfg),
|
guard: protoguard.New(guardCfg),
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
|
tlsActive: alreadyTLS,
|
||||||
|
log: protolog.NewSessionLogger(logger, "imap"),
|
||||||
state: NotAuthenticated,
|
state: NotAuthenticated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +78,11 @@ func (s *Session) State() State { return s.state }
|
|||||||
func (s *Session) Serve(ctx context.Context) {
|
func (s *Session) Serve(ctx context.Context) {
|
||||||
defer func() { _ = s.conn.Close() }()
|
defer func() { _ = s.conn.Close() }()
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (ING-08): strukturierte Logs mit
|
||||||
|
// Korrelations-ID über die gesamte Verbindungsdauer.
|
||||||
|
s.log.Event(ctx, "session_start", slog.String("remote_addr", s.conn.RemoteAddr().String()))
|
||||||
|
defer s.log.Event(ctx, "session_end")
|
||||||
|
|
||||||
if err := writeUntagged(s.writer, "OK IMAP4rev1 Service Ready"); err != nil {
|
if err := writeUntagged(s.writer, "OK IMAP4rev1 Service Ready"); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,6 +116,10 @@ func (s *Session) Serve(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 (ING-08): LOGIN-Argumente (Passwort)
|
||||||
|
// erscheinen über RedactCommandLine nie im Klartext im Log.
|
||||||
|
s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(cmd.Name, cmd.Args)))
|
||||||
|
|
||||||
if !s.dispatch(ctx, cmd) {
|
if !s.dispatch(ctx, cmd) {
|
||||||
return // LOGOUT oder nicht behebbarer Schreibfehler
|
return // LOGOUT oder nicht behebbarer Schreibfehler
|
||||||
}
|
}
|
||||||
@@ -142,6 +161,8 @@ func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
|||||||
return s.handleUIDFetch(ctx, cmd)
|
return s.handleUIDFetch(ctx, cmd)
|
||||||
case "LOGOUT":
|
case "LOGOUT":
|
||||||
return s.handleLogout(cmd)
|
return s.handleLogout(cmd)
|
||||||
|
case "STARTTLS":
|
||||||
|
return s.handleStartTLS(ctx, cmd)
|
||||||
default:
|
default:
|
||||||
return s.writeErr(cmd.Tag, "BAD", "Unknown command")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package pop3
|
package pop3
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// genericAuthFailure ist bewusst IMMER derselbe Text, unabhängig davon,
|
// 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 {
|
if len(cmd.Args) != 1 {
|
||||||
return writeErr(s.writer, "PASS requires a password") == nil
|
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 {
|
if s.auth == nil {
|
||||||
return writeErr(s.writer, genericAuthFailure) == 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")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startLoggedTestServer(t *testing.T, logger *slog.Logger) (addr string, stop func()) {
|
||||||
|
t.Helper()
|
||||||
|
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
||||||
|
store := newFakeMailboxStore()
|
||||||
|
srv := NewServerWithGuardTLSAndLogger(auth, store, protoguard.DefaultConfig(), nil, logger)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFullSession(t *testing.T, addr string) {
|
||||||
|
t.Helper()
|
||||||
|
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"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("STAT\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("QUIT\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_RedactsCredentialsInRealSessionLog ist die geforderte
|
||||||
|
// Pflichtprüfung 1 (ING-08): Redaktion sensibler Felder in ALLEN
|
||||||
|
// Log-Pfaden — hier gegen den echten, laufenden POP3-Server geprüft,
|
||||||
|
// nicht nur gegen die protolog-Bausteine isoliert.
|
||||||
|
func TestProtolog_RedactsCredentialsInRealSessionLog(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addr, stop := startLoggedTestServer(t, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
if strings.Contains(logged, "geheim123") {
|
||||||
|
t.Fatalf("passwort im klartext im log gefunden:\n%s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, "PASS [REDACTED]") {
|
||||||
|
t.Fatalf("erwartete redigierten PASS-eintrag im log, habe:\n%s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_SessionFullyReconstructableByCorrelationID ist die
|
||||||
|
// geforderte Pflichtprüfung 2 (ING-08): eine komplette Session ist über
|
||||||
|
// die Korrelations-ID lückenlos rekonstruierbar — Stichprobe aus
|
||||||
|
// mehreren gleichzeitig geloggten Sessions.
|
||||||
|
func TestProtolog_SessionFullyReconstructableByCorrelationID(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addr, stop := startLoggedTestServer(t, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// Zwei Sessions nacheinander, damit sich die Logs im gemeinsamen
|
||||||
|
// Puffer mischen — realistischer als eine einzelne isolierte Session.
|
||||||
|
runFullSession(t, addr)
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
all, err := protolog.Reconstruct(bytes.NewReader(buf.Bytes()), "does-not-exist")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconstruct (kontrollaufruf): %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 0 {
|
||||||
|
t.Fatalf("unerwartete treffer für nicht existierende id: %d", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erste correlation_id aus dem rohen Log extrahieren (erste Zeile =
|
||||||
|
// session_start der ersten Session).
|
||||||
|
firstLine := strings.SplitN(buf.String(), "\n", 2)[0]
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(firstLine), &raw); err != nil {
|
||||||
|
t.Fatalf("erste logzeile parsen: %v", err)
|
||||||
|
}
|
||||||
|
firstID, _ := raw["correlation_id"].(string)
|
||||||
|
if firstID == "" {
|
||||||
|
t.Fatalf("keine correlation_id in erster logzeile: %s", firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := protolog.Reconstruct(bytes.NewReader(buf.Bytes()), firstID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconstruct: %v", err)
|
||||||
|
}
|
||||||
|
// session_start, 4 kommandos (USER/PASS/STAT/QUIT), session_end.
|
||||||
|
if len(entries) != 6 {
|
||||||
|
t.Fatalf("erwartete 6 lückenlose einträge für die session, habe %d: %+v", len(entries), entries)
|
||||||
|
}
|
||||||
|
if entries[0].Msg != "session_start" || entries[len(entries)-1].Msg != "session_end" {
|
||||||
|
t.Fatalf("session nicht lückenlos rekonstruierbar (start/ende falsch): %+v", entries)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.CorrelationID != firstID {
|
||||||
|
t.Fatalf("eintrag mit falscher correlation_id in rekonstruktion: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_LoggingDoesNotRelevantlyImpactThroughput ist die
|
||||||
|
// geforderte Pflichtprüfung 3 (ING-08): Lasttest bestätigt, dass
|
||||||
|
// Logging die Durchsatzrate nicht relevant beeinträchtigt.
|
||||||
|
func TestProtolog_LoggingDoesNotRelevantlyImpactThroughput(t *testing.T) {
|
||||||
|
const sessions = 100
|
||||||
|
|
||||||
|
// Ohne Logging (logger nil -> protolog.Event ist no-op).
|
||||||
|
addrOff, stopOff := startLoggedTestServer(t, nil)
|
||||||
|
startOff := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOff)
|
||||||
|
}
|
||||||
|
durationOff := time.Since(startOff)
|
||||||
|
stopOff()
|
||||||
|
|
||||||
|
// Mit Logging in einen echten (verworfenen) Puffer.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
addrOn, stopOn := startLoggedTestServer(t, logger)
|
||||||
|
startOn := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOn)
|
||||||
|
}
|
||||||
|
durationOn := time.Since(startOn)
|
||||||
|
stopOn()
|
||||||
|
|
||||||
|
// Großzügige Toleranz (Faktor 3): Ziel ist der Ausschluss eines
|
||||||
|
// GROBEN Regressionsfaktors (z. B. synchrones Schreiben pro
|
||||||
|
// Byte, blockierendes I/O ohne Puffer), nicht eine exakte
|
||||||
|
// Performance-Zusicherung — Testläufe auf geteilten CI-Hosts
|
||||||
|
// schwanken.
|
||||||
|
if durationOn > 3*durationOff+5*time.Millisecond {
|
||||||
|
t.Fatalf("logging verlangsamt durchsatz relevant: ohne=%v, mit=%v", durationOff, durationOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package pop3
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
@@ -11,12 +13,18 @@ import (
|
|||||||
|
|
||||||
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
|
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
|
||||||
// Goroutine (Akzeptanzkriterium 1) — gleiches Muster wie
|
// Goroutine (Akzeptanzkriterium 1) — gleiches Muster wie
|
||||||
// mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht
|
// mail/internal/imap.Server. tlsConfig ist optional: nil bedeutet, STLS
|
||||||
// dieser Kachel.
|
// 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 {
|
type Server struct {
|
||||||
auth Authenticator
|
auth Authenticator
|
||||||
store MailboxStore
|
store MailboxStore
|
||||||
guardCfg protoguard.Config
|
guardCfg protoguard.Config
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||||
@@ -30,6 +38,20 @@ func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg p
|
|||||||
return &Server{auth: auth, store: store, guardCfg: guardCfg}
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerWithGuardTLSAndLogger erlaubt zusätzlich strukturiertes
|
||||||
|
// Protokoll-Logging (ING-08). logger darf nil sein (Logging dann
|
||||||
|
// deaktiviert, Rückwärtskompatibilität zu ING-01..ING-07).
|
||||||
|
func NewServerWithGuardTLSAndLogger(auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config, logger *slog.Logger) *Server {
|
||||||
|
return &Server{auth: auth, store: store, guardCfg: guardCfg, tlsConfig: tlsConfig, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
||||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -49,7 +71,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("pop3: verbindung annehmen: %w", err)
|
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, srv.logger)
|
||||||
go session.Serve(ctx)
|
go session.Serve(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package pop3
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser
|
// phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser
|
||||||
@@ -34,13 +37,23 @@ type Session struct {
|
|||||||
store MailboxStore
|
store MailboxStore
|
||||||
guard *protoguard.Guard
|
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
|
||||||
|
|
||||||
|
log *protolog.SessionLogger // ING-08, nie nil (aber log.Event() ist nil-sicher)
|
||||||
|
|
||||||
state State
|
state State
|
||||||
pendingUsername string // nach USER, vor erfolgreichem PASS
|
pendingUsername string // nach USER, vor erfolgreichem PASS
|
||||||
username string // nach erfolgreichem PASS
|
username string // nach erfolgreichem PASS
|
||||||
deleted map[int]bool
|
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, logger *slog.Logger) *Session {
|
||||||
|
_, alreadyTLS := conn.(*tls.Conn)
|
||||||
return &Session{
|
return &Session{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||||
@@ -48,6 +61,9 @@ func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg
|
|||||||
auth: auth,
|
auth: auth,
|
||||||
store: store,
|
store: store,
|
||||||
guard: protoguard.New(guardCfg),
|
guard: protoguard.New(guardCfg),
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
|
tlsActive: alreadyTLS,
|
||||||
|
log: protolog.NewSessionLogger(logger, "pop3"),
|
||||||
state: Authorization,
|
state: Authorization,
|
||||||
deleted: map[int]bool{},
|
deleted: map[int]bool{},
|
||||||
}
|
}
|
||||||
@@ -68,6 +84,11 @@ func (s *Session) State() State { return s.state }
|
|||||||
func (s *Session) Serve(ctx context.Context) {
|
func (s *Session) Serve(ctx context.Context) {
|
||||||
defer func() { _ = s.conn.Close() }()
|
defer func() { _ = s.conn.Close() }()
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (ING-08): strukturierte Logs mit
|
||||||
|
// Korrelations-ID über die gesamte Verbindungsdauer.
|
||||||
|
s.log.Event(ctx, "session_start", slog.String("remote_addr", s.conn.RemoteAddr().String()))
|
||||||
|
defer s.log.Event(ctx, "session_end")
|
||||||
|
|
||||||
if err := writeOK(s.writer, "POP3 server ready"); err != nil {
|
if err := writeOK(s.writer, "POP3 server ready"); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,6 +125,12 @@ func (s *Session) Serve(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 (ING-08): Zugangsdaten (PASS-Argument)
|
||||||
|
// erscheinen über RedactCommandLine nie im Klartext im Log.
|
||||||
|
// Nachrichteninhalte werden hier grundsätzlich nicht geloggt —
|
||||||
|
// RETR/LIST-Antworten sind kein Bestandteil dieses Ereignisses.
|
||||||
|
s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(cmd.Name, cmd.Args)))
|
||||||
|
|
||||||
if !s.dispatch(ctx, cmd) {
|
if !s.dispatch(ctx, cmd) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,6 +166,8 @@ func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
|||||||
return s.handleDele(cmd)
|
return s.handleDele(cmd)
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
return s.handleQuit(ctx)
|
return s.handleQuit(ctx)
|
||||||
|
case "STLS":
|
||||||
|
return s.handleStls(ctx)
|
||||||
default:
|
default:
|
||||||
return writeErr(s.writer, "unknown command") == nil
|
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,68 @@
|
|||||||
|
package protolog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry ist ein einzelner strukturierter Logeintrag, wie ihn
|
||||||
|
// slog.NewJSONHandler schreibt.
|
||||||
|
type Entry struct {
|
||||||
|
Time string
|
||||||
|
Level string
|
||||||
|
Msg string
|
||||||
|
CorrelationID string
|
||||||
|
Protocol string
|
||||||
|
// Fields enthält alle weiteren Felder des Eintrags (auch time/
|
||||||
|
// level/msg/correlation_id/protocol nochmals, der Einfachheit
|
||||||
|
// halber), für Diagnosewerkzeuge, die zusätzliche Attribute
|
||||||
|
// auswerten wollen.
|
||||||
|
Fields map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct liest zeilenweise JSON-Logs aus r und liefert,
|
||||||
|
// in Log-Reihenfolge, ausschließlich die Einträge mit passender
|
||||||
|
// correlation_id — das geforderte Diagnosewerkzeug
|
||||||
|
// (Akzeptanzkriterium 3): eine einzelne Session vollständig anhand
|
||||||
|
// ihrer Korrelations-ID nachvollziehbar.
|
||||||
|
func Reconstruct(r io.Reader, correlationID string) ([]Entry, error) {
|
||||||
|
var result []Entry
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(line, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("protolog: log-zeile %d parsen: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
cid, _ := raw["correlation_id"].(string)
|
||||||
|
if cid != correlationID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := Entry{CorrelationID: cid, Fields: raw}
|
||||||
|
if v, ok := raw["time"].(string); ok {
|
||||||
|
entry.Time = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["level"].(string); ok {
|
||||||
|
entry.Level = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["msg"].(string); ok {
|
||||||
|
entry.Msg = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["protocol"].(string); ok {
|
||||||
|
entry.Protocol = v
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("protolog: log lesen: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package protolog implementiert ING-08: strukturiertes Logging für
|
||||||
|
// IMAP-/POP3-/SMTP-Sessions mit Korrelations-ID (Akzeptanzkriterium 1),
|
||||||
|
// Redaktion sensibler Felder (Akzeptanzkriterium 2) und ein
|
||||||
|
// Diagnosewerkzeug, das eine einzelne Session anhand ihrer
|
||||||
|
// Korrelations-ID aus den Logs rekonstruiert (Akzeptanzkriterium 3,
|
||||||
|
// diagnose.go).
|
||||||
|
package protolog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCorrelationID erzeugt eine zufällige, session-eindeutige
|
||||||
|
// Korrelations-ID.
|
||||||
|
func NewCorrelationID() string {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
// crypto/rand.Read schlägt praktisch nie fehl; ein Nullwert würde
|
||||||
|
// höchstens zu einer unwahrscheinlichen ID-Kollision führen, kein
|
||||||
|
// Sicherheitsproblem für ein reines Diagnosemerkmal.
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionLogger loggt strukturierte Ereignisse EINER Verbindung mit
|
||||||
|
// fester Korrelations-ID über deren gesamte Dauer (Akzeptanzkriterium
|
||||||
|
// 1). Ein SessionLogger mit logger == nil ist sicher benutzbar und
|
||||||
|
// loggt nichts (Standard für Server ohne konfigurierten Logger).
|
||||||
|
type SessionLogger struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
correlationID string
|
||||||
|
protocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionLogger erstellt einen SessionLogger mit frischer
|
||||||
|
// Korrelations-ID. logger darf nil sein (Logging dann deaktiviert).
|
||||||
|
func NewSessionLogger(logger *slog.Logger, protocol string) *SessionLogger {
|
||||||
|
return &SessionLogger{logger: logger, correlationID: NewCorrelationID(), protocol: protocol}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorrelationID liefert die Korrelations-ID dieser Session.
|
||||||
|
func (l *SessionLogger) CorrelationID() string {
|
||||||
|
if l == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.correlationID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event loggt EIN strukturiertes Ereignis mit correlation_id und
|
||||||
|
// protocol als festen Feldern. attrs dürfen NIE Zugangsdaten oder
|
||||||
|
// Nachrichteninhalte enthalten — siehe RedactCommandLine für
|
||||||
|
// Kommandozeilen (Akzeptanzkriterium 2).
|
||||||
|
func (l *SessionLogger) Event(ctx context.Context, event string, attrs ...slog.Attr) {
|
||||||
|
if l == nil || l.logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := make([]slog.Attr, 0, len(attrs)+2)
|
||||||
|
all = append(all, slog.String("correlation_id", l.correlationID), slog.String("protocol", l.protocol))
|
||||||
|
all = append(all, attrs...)
|
||||||
|
l.logger.LogAttrs(ctx, slog.LevelInfo, event, all...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package protolog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRedactCommandLine_HidesCredentials ist Teil der geforderten
|
||||||
|
// Pflichtprüfung 1 (ING-08): Redaktion sensibler Felder.
|
||||||
|
func TestRedactCommandLine_HidesCredentials(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
verb string
|
||||||
|
args []string
|
||||||
|
wantSafe bool // true: darf das geheimnis NICHT enthalten
|
||||||
|
secret string
|
||||||
|
}{
|
||||||
|
{"PASS", []string{"geheim123"}, true, "geheim123"},
|
||||||
|
{"LOGIN", []string{"alice", "geheim123"}, true, "geheim123"},
|
||||||
|
{"AUTH", []string{"PLAIN", "AGFsaWNlAGdlaGVpbTEyMw=="}, true, "AGFsaWNlAGdlaGVpbTEyMw=="},
|
||||||
|
{"STAT", nil, false, ""},
|
||||||
|
{"USER", []string{"alice"}, false, "alice"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
out := RedactCommandLine(tc.verb, tc.args)
|
||||||
|
if tc.wantSafe && strings.Contains(out, tc.secret) {
|
||||||
|
t.Fatalf("%s: geheimnis im klartext gefunden: %q", tc.verb, out)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(out, strings.ToUpper(tc.verb)) {
|
||||||
|
t.Fatalf("%s: kommandoname fehlt in redigierter zeile: %q", tc.verb, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionLogger_EventNeverContainsRawMessage stellt sicher, dass
|
||||||
|
// über die reguläre Event-API keine Nachrichteninhalte geloggt werden
|
||||||
|
// können, ohne dass der Aufrufer sie explizit (und damit sichtbar im
|
||||||
|
// Code) als Attribut übergibt — Event selbst fügt nie Rohinhalte hinzu.
|
||||||
|
func TestSessionLogger_EventNeverContainsRawMessage(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
sl := NewSessionLogger(logger, "pop3")
|
||||||
|
|
||||||
|
sl.Event(context.Background(), "command", slog.String("command", RedactCommandLine("PASS", []string{"geheim123"})))
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), "geheim123") {
|
||||||
|
t.Fatalf("passwort im log gefunden: %s", buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), sl.CorrelationID()) {
|
||||||
|
t.Fatalf("correlation_id fehlt im log: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconstruct_ReturnsOnlyMatchingSessionInOrder ist die geforderte
|
||||||
|
// Pflichtprüfung 2 (ING-08): eine komplette Session ist über die
|
||||||
|
// Korrelations-ID lückenlos rekonstruierbar, aus einem Log mit
|
||||||
|
// mehreren gemischten Sessions.
|
||||||
|
func TestReconstruct_ReturnsOnlyMatchingSessionInOrder(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
|
||||||
|
target := NewSessionLogger(logger, "imap")
|
||||||
|
other := NewSessionLogger(logger, "imap")
|
||||||
|
|
||||||
|
target.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:1"))
|
||||||
|
other.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:2"))
|
||||||
|
target.Event(context.Background(), "command", slog.String("command", "LOGIN [REDACTED]"))
|
||||||
|
other.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
|
||||||
|
target.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
|
||||||
|
target.Event(context.Background(), "session_end")
|
||||||
|
other.Event(context.Background(), "session_end")
|
||||||
|
|
||||||
|
entries, err := Reconstruct(&buf, target.CorrelationID())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconstruct: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 4 {
|
||||||
|
t.Fatalf("erwartete 4 einträge für die zielsession, habe %d", len(entries))
|
||||||
|
}
|
||||||
|
wantMsgs := []string{"session_start", "command", "command", "session_end"}
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Msg != wantMsgs[i] {
|
||||||
|
t.Fatalf("eintrag %d: erwartete msg %q, habe %q", i, wantMsgs[i], e.Msg)
|
||||||
|
}
|
||||||
|
if e.CorrelationID != target.CorrelationID() {
|
||||||
|
t.Fatalf("eintrag %d gehört zur falschen session", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package protolog
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// sensitiveCommandVerbs sind Kommandos, deren Argumente Zugangsdaten
|
||||||
|
// enthalten können (Akzeptanzkriterium 2: Zugangsdaten erscheinen nie
|
||||||
|
// im Klartext im Log). PASS (POP3), LOGIN (IMAP) tragen das Passwort
|
||||||
|
// direkt als Argument; AUTH ist für zukünftige SMTP-Authentifizierung
|
||||||
|
// vorsorglich mit aufgenommen, auch wenn ING-03 kein AUTH implementiert.
|
||||||
|
var sensitiveCommandVerbs = map[string]bool{
|
||||||
|
"PASS": true,
|
||||||
|
"LOGIN": true,
|
||||||
|
"AUTH": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactCommandLine liefert eine loggbare Darstellung einer
|
||||||
|
// Kommandozeile: das Kommando (Verb) bleibt sichtbar — wichtig für die
|
||||||
|
// Diagnose (Akzeptanzkriterium 3) —, Argumente sensibler Kommandos
|
||||||
|
// werden vollständig durch "[REDACTED]" ersetzt statt einzeln
|
||||||
|
// geparst, damit auch unerwartet platzierte Zugangsdaten (z. B. ein
|
||||||
|
// Benutzername, der zufällig wie ein Passwort aussieht) nicht
|
||||||
|
// versehentlich durchrutschen.
|
||||||
|
func RedactCommandLine(verb string, args []string) string {
|
||||||
|
verbUpper := strings.ToUpper(verb)
|
||||||
|
if sensitiveCommandVerbs[verbUpper] {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return verbUpper
|
||||||
|
}
|
||||||
|
return verbUpper + " [REDACTED]"
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return verbUpper
|
||||||
|
}
|
||||||
|
return verbUpper + " " + strings.Join(args, " ")
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package smtp
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Session) handleHelo(verb, arg string) bool {
|
func (s *Session) handleHelo(verb, arg string) bool {
|
||||||
@@ -17,11 +20,48 @@ func (s *Session) handleHelo(verb, arg string) bool {
|
|||||||
s.state = Ready
|
s.state = Ready
|
||||||
|
|
||||||
if verb == "EHLO" {
|
if verb == "EHLO" {
|
||||||
return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil
|
lines := []string{"nexarch-mail greets " + arg, "8BITMIME"}
|
||||||
|
if s.tlsConfig != nil && !s.tlsActive {
|
||||||
|
lines = append(lines, "STARTTLS")
|
||||||
|
}
|
||||||
|
return s.replyMultiline(250, lines) == nil
|
||||||
}
|
}
|
||||||
return s.reply(250, "nexarch-mail greets "+arg) == nil
|
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):
|
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
||||||
// die Absenderadresse wird vor der Annahme validiert.
|
// die Absenderadresse wird vor der Annahme validiert.
|
||||||
func (s *Session) handleMailFrom(arg string) bool {
|
func (s *Session) handleMailFrom(arg string) bool {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startLoggedTestServer(t *testing.T, sink MessageSink, logger *slog.Logger) (addr string, stop func()) {
|
||||||
|
t.Helper()
|
||||||
|
srv := NewServerWithMaxMessageBytesTLSAndLogger(sink, defaultMaxMessageBytes, nil, logger)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFullSession(t *testing.T, addr string) {
|
||||||
|
t.Helper()
|
||||||
|
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("MAIL FROM:<a@example.com>\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("RCPT TO:<b@example.com>\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("DATA\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("Subject: geheime betreffzeile Passwort=geheim123\r\n\r\nGeheimer Nachrichtentext.\r\n.\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
_, _ = conn.Write([]byte("QUIT\r\n"))
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_NeverLogsMessageBodyOrRedactsCredentials ist die
|
||||||
|
// geforderte Pflichtprüfung 1 (ING-08) gegen den echten, laufenden
|
||||||
|
// SMTP-Server: Nachrichteninhalte (DATA-Body, hier bewusst mit einem
|
||||||
|
// eingebetteten "Passwort=geheim123" versehen) erscheinen NIE im Log,
|
||||||
|
// weil DATA-Zeilen strukturell gar nicht durch den Kommando-Logpfad
|
||||||
|
// laufen.
|
||||||
|
func TestProtolog_NeverLogsMessageBodyOrRedactsCredentials(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
sink := &fakeSink{}
|
||||||
|
addr, stop := startLoggedTestServer(t, sink, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
if sink.count() != 1 {
|
||||||
|
t.Fatalf("testaufbau fehlerhaft: erwartete 1 angenommene nachricht, habe %d", sink.count())
|
||||||
|
}
|
||||||
|
logged := buf.String()
|
||||||
|
if strings.Contains(logged, "geheim123") {
|
||||||
|
t.Fatalf("nachrichteninhalt (mit eingebettetem geheimnis) im log gefunden:\n%s", logged)
|
||||||
|
}
|
||||||
|
if strings.Contains(logged, "Geheimer Nachrichtentext") {
|
||||||
|
t.Fatalf("nachrichtentext im log gefunden:\n%s", logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_SessionFullyReconstructableByCorrelationID ist die
|
||||||
|
// geforderte Pflichtprüfung 2 (ING-08).
|
||||||
|
func TestProtolog_SessionFullyReconstructableByCorrelationID(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
sink := &fakeSink{}
|
||||||
|
addr, stop := startLoggedTestServer(t, sink, logger)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
runFullSession(t, addr)
|
||||||
|
runFullSession(t, addr)
|
||||||
|
|
||||||
|
firstLine := strings.SplitN(buf.String(), "\n", 2)[0]
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(firstLine), &raw); err != nil {
|
||||||
|
t.Fatalf("erste logzeile parsen: %v", err)
|
||||||
|
}
|
||||||
|
firstID, _ := raw["correlation_id"].(string)
|
||||||
|
if firstID == "" {
|
||||||
|
t.Fatalf("keine correlation_id in erster logzeile: %s", firstLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := protolog.Reconstruct(bytes.NewReader(buf.Bytes()), firstID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconstruct: %v", err)
|
||||||
|
}
|
||||||
|
// session_start, 4 kommandos (EHLO/MAIL/RCPT/DATA), session_end.
|
||||||
|
// QUIT wird VOR seiner eigenen Verarbeitung noch geloggt, danach
|
||||||
|
// endet die Sitzung -> zusätzlich 1 kommando-eintrag für QUIT.
|
||||||
|
if len(entries) != 7 {
|
||||||
|
t.Fatalf("erwartete 7 lückenlose einträge für die session, habe %d: %+v", len(entries), entries)
|
||||||
|
}
|
||||||
|
if entries[0].Msg != "session_start" || entries[len(entries)-1].Msg != "session_end" {
|
||||||
|
t.Fatalf("session nicht lückenlos rekonstruierbar: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtolog_LoggingDoesNotRelevantlyImpactThroughput ist die
|
||||||
|
// geforderte Pflichtprüfung 3 (ING-08).
|
||||||
|
func TestProtolog_LoggingDoesNotRelevantlyImpactThroughput(t *testing.T) {
|
||||||
|
const sessions = 100
|
||||||
|
|
||||||
|
sinkOff := &fakeSink{}
|
||||||
|
addrOff, stopOff := startLoggedTestServer(t, sinkOff, nil)
|
||||||
|
startOff := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOff)
|
||||||
|
}
|
||||||
|
durationOff := time.Since(startOff)
|
||||||
|
stopOff()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
sinkOn := &fakeSink{}
|
||||||
|
addrOn, stopOn := startLoggedTestServer(t, sinkOn, logger)
|
||||||
|
startOn := time.Now()
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
runFullSession(t, addrOn)
|
||||||
|
}
|
||||||
|
durationOn := time.Since(startOn)
|
||||||
|
stopOn()
|
||||||
|
|
||||||
|
if durationOn > 3*durationOff+5*time.Millisecond {
|
||||||
|
t.Fatalf("logging verlangsamt durchsatz relevant: ohne=%v, mit=%v", durationOff, durationOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package smtp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,12 +15,14 @@ const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB
|
|||||||
|
|
||||||
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
|
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
|
||||||
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
|
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
|
||||||
// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06,
|
// mail/internal/pop3.Server. tlsConfig ist optional (ING-06): nil
|
||||||
// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 —
|
// bedeutet, STARTTLS ist nicht verfügbar. Rate-Limiting ist Sache von
|
||||||
// keine dieser Kacheln.
|
// ING-09, Protokoll-Logging Sache von ING-08 — keine dieser Kacheln.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
sink MessageSink
|
sink MessageSink
|
||||||
maxMessageBytes int64
|
maxMessageBytes int64
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(sink MessageSink) *Server {
|
func NewServer(sink MessageSink) *Server {
|
||||||
@@ -31,6 +35,20 @@ func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Serv
|
|||||||
return &Server{sink: sink, maxMessageBytes: maxMessageBytes}
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerWithMaxMessageBytesTLSAndLogger erlaubt zusätzlich
|
||||||
|
// strukturiertes Protokoll-Logging (ING-08). logger darf nil sein
|
||||||
|
// (Logging dann deaktiviert, Rückwärtskompatibilität zu ING-01..ING-06).
|
||||||
|
func NewServerWithMaxMessageBytesTLSAndLogger(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger) *Server {
|
||||||
|
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
||||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -50,7 +68,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
|
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
|
||||||
}
|
}
|
||||||
session := newSession(conn, srv.sink, srv.maxMessageBytes)
|
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig, srv.logger)
|
||||||
go session.Serve(ctx)
|
go session.Serve(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package smtp
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
|
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
|
||||||
@@ -25,18 +29,27 @@ type Session struct {
|
|||||||
|
|
||||||
maxMessageBytes int64
|
maxMessageBytes int64
|
||||||
|
|
||||||
|
tlsConfig *tls.Config // nil = kein STARTTLS angeboten (ING-06)
|
||||||
|
tlsActive bool
|
||||||
|
|
||||||
|
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
|
||||||
|
|
||||||
state State
|
state State
|
||||||
from string
|
from string
|
||||||
to []string
|
to []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64) *Session {
|
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger) *Session {
|
||||||
|
_, alreadyTLS := conn.(*tls.Conn)
|
||||||
return &Session{
|
return &Session{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||||
writer: bufio.NewWriter(conn),
|
writer: bufio.NewWriter(conn),
|
||||||
sink: sink,
|
sink: sink,
|
||||||
maxMessageBytes: maxMessageBytes,
|
maxMessageBytes: maxMessageBytes,
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
|
tlsActive: alreadyTLS,
|
||||||
|
log: protolog.NewSessionLogger(logger, "smtp"),
|
||||||
state: Greeting,
|
state: Greeting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +61,11 @@ func (s *Session) State() State { return s.state }
|
|||||||
func (s *Session) Serve(ctx context.Context) {
|
func (s *Session) Serve(ctx context.Context) {
|
||||||
defer func() { _ = s.conn.Close() }()
|
defer func() { _ = s.conn.Close() }()
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (ING-08): strukturierte Logs mit
|
||||||
|
// Korrelations-ID über die gesamte Verbindungsdauer.
|
||||||
|
s.log.Event(ctx, "session_start", slog.String("remote_addr", s.conn.RemoteAddr().String()))
|
||||||
|
defer s.log.Event(ctx, "session_end")
|
||||||
|
|
||||||
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
|
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,6 +80,17 @@ func (s *Session) Serve(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
verb, arg := parseCommand(line)
|
verb, arg := parseCommand(line)
|
||||||
|
// Akzeptanzkriterium 2 (ING-08): sensible Argumente (z. B. ein
|
||||||
|
// künftiges AUTH) erscheinen über RedactCommandLine nie im
|
||||||
|
// Klartext im Log. DATA-Nachrichteninhalte werden hier NICHT
|
||||||
|
// erfasst — nur das Kommando "DATA" selbst, der Body wird an
|
||||||
|
// keiner Stelle geloggt.
|
||||||
|
var args []string
|
||||||
|
if arg != "" {
|
||||||
|
args = strings.Fields(arg)
|
||||||
|
}
|
||||||
|
s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(verb, args)))
|
||||||
|
|
||||||
if !s.dispatch(ctx, verb, arg) {
|
if !s.dispatch(ctx, verb, arg) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,6 +122,8 @@ func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
|
|||||||
return s.handleData(ctx)
|
return s.handleData(ctx)
|
||||||
case "RSET":
|
case "RSET":
|
||||||
return s.handleRset()
|
return s.handleRset()
|
||||||
|
case "STARTTLS":
|
||||||
|
return s.handleStartTLS(ctx)
|
||||||
case "NOOP":
|
case "NOOP":
|
||||||
return s.reply(250, "OK") == nil
|
return s.reply(250, "OK") == nil
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
|
|||||||
@@ -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