feat(auth): konfigurierbare TLS-Mindestversion fuer den Verzeichnisdienst

Domain Controller auf Windows Server 2012 R2 koennen TLS 1.2 per Registry
deaktiviert haben. ad.min_tls_version (Default 1.2) erlaubt es, fuer solche
Altsysteme auf 1.0/1.1 herunterzugehen; das Portal warnt dann bei jedem Start.

Die Zertifikatspruefung bleibt in jedem Fall aktiv — fuer LDAP gibt es
weiterhin keine Insecure-Option. Im Legacy-Modus kommen lediglich die
CBC-SHA256-Suiten hinzu, die Go als unsicher einstuft; RC4, 3DES und Suiten
ohne Forward Secrecy werden nie angeboten.

README dokumentiert zusaetzlich die beiden anderen 2012-R2-Stolpersteine:
SHA-1-signierte DC-Zertifikate (seit Go 1.24 nicht mehr umgehbar) und
RSA-Schluesselaustausch (nur ueber GODEBUG=tlsrsakex=1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 13:18:24 +02:00
parent a2bce25424
commit 0fc2d8808b
10 changed files with 299 additions and 25 deletions

View file

@ -31,7 +31,10 @@ type Options struct {
BindPassword string
VPNGroup string
CAFile string
Timeout time.Duration
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version (Konstante aus
// crypto/tls). 0 bedeutet TLS 1.2.
MinTLSVersion uint16
Timeout time.Duration
// Dial ist injizierbar; nil = echte LDAP-Verbindung.
Dial func(ctx context.Context, server string) (conn, error)
@ -73,7 +76,7 @@ func NewAD(opts Options) (*AD, error) {
opts.Port = 636
}
if opts.Dial == nil {
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.Timeout)
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.MinTLSVersion, opts.Timeout)
}
a := &AD{opts: opts}
if opts.OnFailover != nil {

View file

@ -26,10 +26,36 @@ type ldapConn struct{ *ldap.Conn }
func (c ldapConn) Close() error { c.Conn.Close(); return nil }
// tlsConfigFor baut die TLS-Konfiguration. Verifikation ist immer aktiv —
// für LDAP gibt es bewusst keine Insecure-Option.
func tlsConfigFor(server, caFile string) (*tls.Config, error) {
cfg := &tls.Config{ServerName: server, MinVersion: tls.VersionTLS12}
// ParseTLSVersion übersetzt "1.0".."1.3" in die Konstante von crypto/tls.
// Ein leerer oder unbekannter Wert ergibt TLS 1.2 — im Zweifel das Sichere.
func ParseTLSVersion(s string) uint16 {
switch s {
case "1.0":
return tls.VersionTLS10
case "1.1":
return tls.VersionTLS11
case "1.3":
return tls.VersionTLS13
default:
return tls.VersionTLS12
}
}
// tlsConfigFor baut die TLS-Konfiguration. Die Zertifikatsprüfung ist immer
// aktiv — für LDAP gibt es bewusst keine Insecure-Option. Lediglich die
// Protokoll-Mindestversion ist konfigurierbar, damit Altsysteme wie ein
// Windows Server 2012 R2 ohne TLS 1.2 erreichbar bleiben.
func tlsConfigFor(server, caFile string, minVersion uint16) (*tls.Config, error) {
if minVersion == 0 {
minVersion = tls.VersionTLS12
}
cfg := &tls.Config{ServerName: server, MinVersion: minVersion}
if minVersion < tls.VersionTLS12 {
// Go bietet die CBC-Suiten alter Schannel-Stacks nicht mehr von sich
// aus an. Ohne sie scheitert der Handshake mit einem 2012 R2, der auf
// TLS 1.0/1.1 festgenagelt ist, bereits an der Cipher-Auswahl.
cfg.CipherSuites = legacyCipherSuites()
}
if caFile == "" {
return cfg, nil
}
@ -45,10 +71,32 @@ func tlsConfigFor(server, caFile string) (*tls.Config, error) {
return cfg, nil
}
// legacyCipherSuites ergänzt Gos sichere Vorgaben um die CBC-SHA256-Suiten,
// die Go als unsicher einstuft und deshalb nicht von sich aus anbietet, die
// aber auf alten Schannel-Stacks vorkommen.
//
// Die Suiten mit reinem RSA-Schlüsselaustausch (TLS_RSA_*) sind bewusst NICHT
// dabei: Go schaltet sie seit 1.22 unabhängig von dieser Liste ab. Braucht ein
// Domain Controller sie zwingend, hilft nur GODEBUG=tlsrsakex=1 in der
// systemd-Unit — siehe README.
//
// Wird nur benutzt, wenn der Betreiber die Mindestversion ausdrücklich unter
// TLS 1.2 gesetzt hat.
func legacyCipherSuites() []uint16 {
out := make([]uint16, 0, len(tls.CipherSuites())+2)
for _, cs := range tls.CipherSuites() {
out = append(out, cs.ID)
}
return append(out,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
)
}
// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb.
func realDialer(port int, tlsMode, caFile string, timeout time.Duration) func(context.Context, string) (conn, error) {
func realDialer(port int, tlsMode, caFile string, minVersion uint16, timeout time.Duration) func(context.Context, string) (conn, error) {
return func(ctx context.Context, server string) (conn, error) {
tlsCfg, err := tlsConfigFor(server, caFile)
tlsCfg, err := tlsConfigFor(server, caFile, minVersion)
if err != nil {
return nil, err
}

View file

@ -0,0 +1,98 @@
package auth
import (
"crypto/tls"
"testing"
)
func TestParseTLSVersion(t *testing.T) {
cases := map[string]uint16{
"1.0": tls.VersionTLS10,
"1.1": tls.VersionTLS11,
"1.2": tls.VersionTLS12,
"1.3": tls.VersionTLS13,
}
for in, want := range cases {
if got := ParseTLSVersion(in); got != want {
t.Errorf("ParseTLSVersion(%q) = %#04x, want %#04x", in, got, want)
}
}
// Unbekanntes oder leeres Eingaben fallen auf das Sichere zurück.
for _, in := range []string{"", "1.4", "unsinn", "TLSv1"} {
if got := ParseTLSVersion(in); got != tls.VersionTLS12 {
t.Errorf("ParseTLSVersion(%q) = %#04x, want TLS 1.2", in, got)
}
}
}
func TestTLSConfigDefaultsToTLS12(t *testing.T) {
cfg, err := tlsConfigFor("dc01.firma.local", "", 0)
if err != nil {
t.Fatal(err)
}
if cfg.MinVersion != tls.VersionTLS12 {
t.Errorf("MinVersion = %#04x, want TLS 1.2", cfg.MinVersion)
}
if cfg.ServerName != "dc01.firma.local" {
t.Errorf("ServerName = %q", cfg.ServerName)
}
if cfg.InsecureSkipVerify {
t.Error("die Zertifikatspruefung darf fuer LDAP niemals abschaltbar sein")
}
if cfg.CipherSuites != nil {
t.Error("bei TLS 1.2+ sollen Gos sichere Vorgaben gelten, keine eigene Liste")
}
}
func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) {
cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS10)
if err != nil {
t.Fatal(err)
}
if cfg.MinVersion != tls.VersionTLS10 {
t.Errorf("MinVersion = %#04x, want TLS 1.0", cfg.MinVersion)
}
if cfg.InsecureSkipVerify {
t.Error("auch im Legacy-Modus bleibt die Zertifikatspruefung aktiv")
}
has := func(id uint16) bool {
for _, got := range cfg.CipherSuites {
if got == id {
return true
}
}
return false
}
// Die CBC-SHA256-Suite fuehrt Go als unsicher und bietet sie sonst nicht an.
if !has(tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256) {
t.Error("ECDHE_RSA_AES_128_CBC_SHA256 fehlt in der Legacy-Liste")
}
// Die CBC-SHA1-Suiten eines 2012 R2 stecken schon in Gos Vorgaben.
if !has(tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA) {
t.Error("ECDHE_RSA_AES_256_CBC_SHA fehlt — die braucht ein 2012 R2 unter TLS 1.0")
}
// Die modernen Suiten duerfen im Legacy-Modus nicht wegfallen.
if !has(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) {
t.Error("die modernen Suiten duerfen im Legacy-Modus nicht wegfallen")
}
// Kein reiner RSA-Schluesselaustausch: keine Forward Secrecy, und Go
// filtert die Suiten ohnehin weg.
for _, id := range []uint16{tls.TLS_RSA_WITH_AES_128_CBC_SHA, tls.TLS_RSA_WITH_AES_256_CBC_SHA} {
if has(id) {
t.Errorf("Suite %#04x ohne Forward Secrecy gehoert nicht in die Liste", id)
}
}
// RC4 und 3DES niemals.
for _, id := range []uint16{tls.TLS_RSA_WITH_RC4_128_SHA, tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA} {
if has(id) {
t.Errorf("veraltete Suite %#04x darf nie angeboten werden", id)
}
}
}
func TestTLSConfigRejectsBadCAFile(t *testing.T) {
if _, err := tlsConfigFor("dc01", "/gibt/es/nicht.pem", tls.VersionTLS12); err == nil {
t.Fatal("fehlende CA-Datei muss abgelehnt werden")
}
}