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

@ -315,6 +315,54 @@ Für die Fehlersuche lässt sich `logging.level` vorübergehend auf `debug`
setzen — dann werden auch die verglichenen CNs protokolliert. **Im
Normalbetrieb wieder auf `info` zurückstellen.**
### Alte Domain Controller (Windows Server 2012 R2 und älter)
Fehlendes TLS 1.3 ist **kein** Problem — das Portal verlangt mindestens
TLS 1.2 und handelt automatisch herunter. Server 2012 R2 beherrscht TLS 1.2
für Schannel standardmäßig. Scheitert die Verbindung trotzdem, ist fast immer
eine dieser drei Ursachen im Spiel. Die Fehlermeldung von `vpnportal check`
sagt, welche:
| Fehlermeldung enthält | Ursache | Behebung |
|---|---|---|
| `protocol version not supported`, `handshake failure` | TLS 1.2 ist auf dem DC per Registry deaktiviert | `ad.min_tls_version: "1.0"` setzen (siehe unten) |
| `insecure algorithm SHA1-RSA`, `certificate signed by unknown authority` | DC-Zertifikat ist mit SHA-1 signiert | **Zertifikat neu ausstellen** — siehe Kasten |
| `tls: no cipher suite supported by both` | DC bietet nur RSA-Schlüsselaustausch | `GODEBUG=tlsrsakex=1` in der Unit setzen |
**TLS-Mindestversion herabsetzen:**
```yaml
ad:
min_tls_version: "1.0" # Default ist 1.2
```
Das Portal warnt dann bei jedem Start. Die **Zertifikatsprüfung bleibt aktiv**
für LDAP gibt es bewusst keine Möglichkeit, sie abzuschalten. Herabgesetzt
werden nur die zulässigen Protokollversionen und die angebotenen
Cipher-Suiten; RC4 und 3DES werden auch dann nie angeboten.
> **SHA-1-Zertifikate lassen sich nicht mehr umgehen.** Go 1.24 hat den
> Notausgang `GODEBUG=x509sha1=1` ersatzlos entfernt. Ist das LDAPS-Zertifikat
> des Domain Controllers mit SHA-1 signiert — bei ADCS-Vorlagen aus der
> 2012-R2-Ära der Normalfall — hilft nur, es mit SHA-256 neu auszustellen.
> Prüfen lässt sich das so:
>
> ```bash
> openssl s_client -connect dc01.firma.local:636 -showcerts </dev/null 2>/dev/null \
> | openssl x509 -noout -text | grep -i "signature algorithm"
> ```
>
> Steht dort `sha1WithRSAEncryption`, muss das Zertifikat getauscht werden.
**RSA-Schlüsselaustausch erzwingen** (letzte Rettung, keine Forward Secrecy).
Go schaltet diese Suiten seit 1.22 ab; sie lassen sich nur global per GODEBUG
reaktivieren. In der systemd-Unit:
```ini
[Service]
Environment=GODEBUG=tlsrsakex=1
```
### Passwort abgelaufen
Das Portal kann keine Passwörter ändern. Ist ein AD-Passwort abgelaufen

View file

@ -71,6 +71,7 @@ func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
BindPassword: cfg.AD.BindPassword,
VPNGroup: cfg.AD.VPNGroup,
CAFile: cfg.AD.CAFile,
MinTLSVersion: auth.ParseTLSVersion(cfg.AD.MinTLSVersion),
Timeout: time.Duration(cfg.AD.Timeout),
})
if err != nil {

View file

@ -78,6 +78,13 @@ func runServe(args []string, stdout, stderr io.Writer) int {
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
}
if cfg.AD.MinTLSVersionIsWeak() {
fmt.Fprintf(stderr,
"WARNUNG: ad.min_tls_version ist auf %s gesetzt — die Verbindung zum "+
"Verzeichnisdienst erlaubt veraltetes TLS. Die Zertifikatsprüfung bleibt "+
"aktiv, aber bitte die Domain Controller auf TLS 1.2 heben.\n",
cfg.AD.MinTLSVersion)
}
fw, ad, err := buildBackends(cfg)
if err != nil {

View file

@ -76,6 +76,15 @@ ad:
# port: 636
# tls_mode: "ldaps"
# Niedrigste akzeptierte TLS-Version zum Verzeichnisdienst.
# Default 1.2. Erlaubt: 1.0, 1.1, 1.2, 1.3.
#
# Nur herabsetzen, wenn die Domain Controller nachweislich kein TLS 1.2
# können (z. B. Windows Server 2012 R2 mit deaktiviertem TLS 1.2). Das
# Portal warnt dann bei jedem Start. Die Zertifikatsprüfung bleibt in jedem
# Fall aktiv — die lässt sich für LDAP bewusst nicht abschalten.
# min_tls_version: "1.2"
# Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen.
bind_user: "svc-vpnportal@firma.local"
bind_password: "BITTE-ERSETZEN"

View file

@ -31,6 +31,9 @@ type Options struct {
BindPassword string
VPNGroup string
CAFile string
// 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.
@ -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")
}
}

View file

@ -64,6 +64,10 @@ type ADConfig struct {
Servers []string `yaml:"servers"`
Port int `yaml:"port"`
TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls"
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version zum
// Verzeichnisdienst: "1.0", "1.1", "1.2" (Default) oder "1.3".
// Unter 1.2 nur für Altsysteme, die nichts Besseres können.
MinTLSVersion string `yaml:"min_tls_version"`
BindUser string `yaml:"bind_user"`
BindPassword string `yaml:"bind_password"`
BindPasswordFile string `yaml:"bind_password_file"`
@ -96,6 +100,7 @@ func Defaults() *Config {
AD: ADConfig{
Port: 636,
TLSMode: "ldaps",
MinTLSVersion: "1.2",
Timeout: Duration(8 * time.Second),
},
Matching: MatchingConfig{CNPattern: "{username}"},

View file

@ -13,6 +13,15 @@ import (
// UsernamePlaceholder ist der Platzhalter in matching.cn_pattern.
const UsernamePlaceholder = "{username}"
// MinTLSVersions sind die zulässigen Werte für ad.min_tls_version.
var MinTLSVersions = []string{"1.0", "1.1", "1.2", "1.3"}
// MinTLSVersionIsWeak meldet, ob die konfigurierte Mindestversion unter dem
// heutigen Stand der Technik liegt. Das Portal warnt dann bei jedem Start.
func (a ADConfig) MinTLSVersionIsWeak() bool {
return a.MinTLSVersion == "1.0" || a.MinTLSVersion == "1.1"
}
// BaseDNFromDomain leitet DC=firma,DC=local aus firma.local ab.
func BaseDNFromDomain(domain string) (string, error) {
domain = strings.Trim(strings.TrimSpace(domain), ".")
@ -101,6 +110,10 @@ func (c *Config) Validate() error {
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode))
}
if !slices.Contains(MinTLSVersions, c.AD.MinTLSVersion) {
errs = append(errs, fmt.Errorf("ad.min_tls_version %q ist ungültig (erlaubt: %s)",
c.AD.MinTLSVersion, strings.Join(MinTLSVersions, ", ")))
}
if c.AD.Port <= 0 || c.AD.Port > 65535 {
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 165535", c.AD.Port))
}

View file

@ -106,6 +106,48 @@ func TestValidateCollectsMultipleErrors(t *testing.T) {
}
}
func TestMinTLSVersionDefaultsTo12(t *testing.T) {
cfg := validCfg(t)
if cfg.AD.MinTLSVersion != "1.2" {
t.Errorf("MinTLSVersion = %q, want Default 1.2", cfg.AD.MinTLSVersion)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Default muss gültig sein: %v", err)
}
}
func TestValidateAcceptsLegacyMinTLSVersions(t *testing.T) {
// Alte Domain Controller (z. B. Windows Server 2012 R2 mit abgeschaltetem
// TLS 1.2) müssen erreichbar bleiben.
for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} {
cfg := validCfg(t)
cfg.AD.MinTLSVersion = v
if err := cfg.Validate(); err != nil {
t.Errorf("min_tls_version %q muss zulässig sein: %v", v, err)
}
}
}
func TestValidateRejectsUnknownMinTLSVersion(t *testing.T) {
for _, v := range []string{"1.4", "tls1.2", "TLSv1", "", "12"} {
cfg := validCfg(t)
cfg.AD.MinTLSVersion = v
if err := cfg.Validate(); err == nil {
t.Errorf("min_tls_version %q muss abgelehnt werden", v)
}
}
}
func TestMinTLSVersionIsWeakBelow12(t *testing.T) {
cfg := validCfg(t)
for v, wantWeak := range map[string]bool{"1.0": true, "1.1": true, "1.2": false, "1.3": false} {
cfg.AD.MinTLSVersion = v
if got := cfg.AD.MinTLSVersionIsWeak(); got != wantWeak {
t.Errorf("MinTLSVersionIsWeak() für %q = %v, want %v", v, got, wantWeak)
}
}
}
func TestValidateRejectsPatternWithoutPlaceholder(t *testing.T) {
cfg := validCfg(t)
cfg.Matching.CNPattern = "fester-cn"