diff --git a/README.md b/README.md index cc135f9..b2c7dce 100644 --- a/README.md +++ b/README.md @@ -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 \ +> | 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 diff --git a/cmd/vpnportal/check.go b/cmd/vpnportal/check.go index a662224..bb23c4c 100644 --- a/cmd/vpnportal/check.go +++ b/cmd/vpnportal/check.go @@ -63,15 +63,16 @@ func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) { return nil, nil, err } ad, err := auth.NewAD(auth.Options{ - Servers: cfg.AD.Servers, - Port: cfg.AD.Port, - TLSMode: cfg.AD.TLSMode, - BaseDN: cfg.EffectiveBaseDN(), - BindUser: cfg.AD.BindUser, - BindPassword: cfg.AD.BindPassword, - VPNGroup: cfg.AD.VPNGroup, - CAFile: cfg.AD.CAFile, - Timeout: time.Duration(cfg.AD.Timeout), + Servers: cfg.AD.Servers, + Port: cfg.AD.Port, + TLSMode: cfg.AD.TLSMode, + BaseDN: cfg.EffectiveBaseDN(), + BindUser: cfg.AD.BindUser, + 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 { return nil, nil, err diff --git a/cmd/vpnportal/serve.go b/cmd/vpnportal/serve.go index 318c5ac..e383d79 100644 --- a/cmd/vpnportal/serve.go +++ b/cmd/vpnportal/serve.go @@ -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 { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 45f3cb7..6f4c1c6 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -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" diff --git a/internal/auth/ad.go b/internal/auth/ad.go index 7d28c8a..d6e6dfe 100644 --- a/internal/auth/ad.go +++ b/internal/auth/ad.go @@ -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 { diff --git a/internal/auth/dial.go b/internal/auth/dial.go index 7edbf9f..0433fb1 100644 --- a/internal/auth/dial.go +++ b/internal/auth/dial.go @@ -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 } diff --git a/internal/auth/dial_test.go b/internal/auth/dial_test.go new file mode 100644 index 0000000..604a365 --- /dev/null +++ b/internal/auth/dial_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 52f381a..ebe6004 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,11 +59,15 @@ type OPNsenseConfig struct { } type ADConfig struct { - Domain string `yaml:"domain"` - BaseDN string `yaml:"base_dn"` - Servers []string `yaml:"servers"` - Port int `yaml:"port"` - TLSMode string `yaml:"tls_mode"` // "ldaps" (Default) oder "starttls" + Domain string `yaml:"domain"` + BaseDN string `yaml:"base_dn"` + 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"` @@ -94,9 +98,10 @@ func Defaults() *Config { Title: "VPN-Portal", }, AD: ADConfig{ - Port: 636, - TLSMode: "ldaps", - Timeout: Duration(8 * time.Second), + Port: 636, + TLSMode: "ldaps", + MinTLSVersion: "1.2", + Timeout: Duration(8 * time.Second), }, Matching: MatchingConfig{CNPattern: "{username}"}, Logging: LoggingConfig{ diff --git a/internal/config/validate.go b/internal/config/validate.go index 4575a77..33fab20 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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 1–65535", c.AD.Port)) } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index d4c3051..31fa32f 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -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"