feat(auth): ad.max_tls_version zum Deckeln der angebotenen TLS-Version
Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello kommentarlos ab: sie nehmen die TCP-Verbindung an, lesen den ClientHello und setzen zurueck, ohne ein Zertifikat zu schicken. Herunterhandeln hilft dann nicht — TLS 1.3 darf gar nicht erst angeboten werden. max_tls_version (Default 1.3) deckelt die Hoechstversion; die Validierung lehnt eine Hoechstversion unterhalb der Mindestversion ab. check erkennt zusaetzlich zurueckgesetzte Verbindungen und nennt die beiden plausiblen Ursachen: fehlendes LDAPS-Zertifikat auf dem Server oder TLS-1.3-Inkompatibilitaet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
fd88251e07
commit
11a3ec2e5f
10 changed files with 136 additions and 10 deletions
|
|
@ -72,6 +72,7 @@ func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
|
||||||
VPNGroup: cfg.AD.VPNGroup,
|
VPNGroup: cfg.AD.VPNGroup,
|
||||||
CAFile: cfg.AD.CAFile,
|
CAFile: cfg.AD.CAFile,
|
||||||
MinTLSVersion: auth.ParseTLSVersion(cfg.AD.MinTLSVersion),
|
MinTLSVersion: auth.ParseTLSVersion(cfg.AD.MinTLSVersion),
|
||||||
|
MaxTLSVersion: auth.ParseTLSVersion(cfg.AD.MaxTLSVersion),
|
||||||
Timeout: time.Duration(cfg.AD.Timeout),
|
Timeout: time.Duration(cfg.AD.Timeout),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,16 @@ ad:
|
||||||
# Fall aktiv — die lässt sich für LDAP bewusst nicht abschalten.
|
# Fall aktiv — die lässt sich für LDAP bewusst nicht abschalten.
|
||||||
# min_tls_version: "1.2"
|
# min_tls_version: "1.2"
|
||||||
|
|
||||||
|
# Hoechste angebotene TLS-Version. Default 1.3.
|
||||||
|
#
|
||||||
|
# Auf "1.2" setzen, wenn der Domain Controller die Verbindung beim
|
||||||
|
# Verbindungsaufbau kommentarlos zuruecksetzt. Alte Schannel-Stacks kommen
|
||||||
|
# mit einem TLS-1.3-ClientHello nicht zurecht und brechen ab, bevor sie ein
|
||||||
|
# Zertifikat schicken. Erkennbar in openssl s_client an:
|
||||||
|
# SSL handshake has read 0 bytes and written <N> bytes
|
||||||
|
# no peer certificate available
|
||||||
|
# max_tls_version: "1.3"
|
||||||
|
|
||||||
# Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen.
|
# Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen.
|
||||||
bind_user: "svc-vpnportal@firma.local"
|
bind_user: "svc-vpnportal@firma.local"
|
||||||
bind_password: "BITTE-ERSETZEN"
|
bind_password: "BITTE-ERSETZEN"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ type Options struct {
|
||||||
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version (Konstante aus
|
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version (Konstante aus
|
||||||
// crypto/tls). 0 bedeutet TLS 1.2.
|
// crypto/tls). 0 bedeutet TLS 1.2.
|
||||||
MinTLSVersion uint16
|
MinTLSVersion uint16
|
||||||
|
// MaxTLSVersion deckelt die angebotene TLS-Version. 0 bedeutet TLS 1.3.
|
||||||
|
MaxTLSVersion uint16
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
|
||||||
// Dial ist injizierbar; nil = echte LDAP-Verbindung.
|
// Dial ist injizierbar; nil = echte LDAP-Verbindung.
|
||||||
|
|
@ -76,7 +78,7 @@ func NewAD(opts Options) (*AD, error) {
|
||||||
opts.Port = 636
|
opts.Port = 636
|
||||||
}
|
}
|
||||||
if opts.Dial == nil {
|
if opts.Dial == nil {
|
||||||
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.MinTLSVersion, opts.Timeout)
|
opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.MinTLSVersion, opts.MaxTLSVersion, opts.Timeout)
|
||||||
}
|
}
|
||||||
a := &AD{opts: opts}
|
a := &AD{opts: opts}
|
||||||
if opts.OnFailover != nil {
|
if opts.OnFailover != nil {
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,17 @@ func ParseTLSVersion(s string) uint16 {
|
||||||
// aktiv — für LDAP gibt es bewusst keine Insecure-Option. Lediglich die
|
// aktiv — für LDAP gibt es bewusst keine Insecure-Option. Lediglich die
|
||||||
// Protokoll-Mindestversion ist konfigurierbar, damit Altsysteme wie ein
|
// Protokoll-Mindestversion ist konfigurierbar, damit Altsysteme wie ein
|
||||||
// Windows Server 2012 R2 ohne TLS 1.2 erreichbar bleiben.
|
// Windows Server 2012 R2 ohne TLS 1.2 erreichbar bleiben.
|
||||||
func tlsConfigFor(server, caFile string, minVersion uint16) (*tls.Config, error) {
|
func tlsConfigFor(server, caFile string, minVersion, maxVersion uint16) (*tls.Config, error) {
|
||||||
if minVersion == 0 {
|
if minVersion == 0 {
|
||||||
minVersion = tls.VersionTLS12
|
minVersion = tls.VersionTLS12
|
||||||
}
|
}
|
||||||
cfg := &tls.Config{ServerName: server, MinVersion: minVersion}
|
if maxVersion == 0 {
|
||||||
|
maxVersion = tls.VersionTLS13
|
||||||
|
}
|
||||||
|
if maxVersion < minVersion {
|
||||||
|
maxVersion = minVersion
|
||||||
|
}
|
||||||
|
cfg := &tls.Config{ServerName: server, MinVersion: minVersion, MaxVersion: maxVersion}
|
||||||
if minVersion < tls.VersionTLS12 {
|
if minVersion < tls.VersionTLS12 {
|
||||||
// Go bietet die CBC-Suiten alter Schannel-Stacks nicht mehr von sich
|
// 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
|
// aus an. Ohne sie scheitert der Handshake mit einem 2012 R2, der auf
|
||||||
|
|
@ -94,9 +100,9 @@ func legacyCipherSuites() []uint16 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb.
|
// realDialer erzeugt die Dial-Funktion für den Produktivbetrieb.
|
||||||
func realDialer(port int, tlsMode, caFile string, minVersion uint16, timeout time.Duration) func(context.Context, string) (conn, error) {
|
func realDialer(port int, tlsMode, caFile string, minVersion, maxVersion uint16, timeout time.Duration) func(context.Context, string) (conn, error) {
|
||||||
return func(ctx context.Context, server string) (conn, error) {
|
return func(ctx context.Context, server string) (conn, error) {
|
||||||
tlsCfg, err := tlsConfigFor(server, caFile, minVersion)
|
tlsCfg, err := tlsConfigFor(server, caFile, minVersion, maxVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ func TestParseTLSVersion(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSConfigDefaultsToTLS12(t *testing.T) {
|
func TestTLSConfigDefaultsToTLS12(t *testing.T) {
|
||||||
cfg, err := tlsConfigFor("dc01.firma.local", "", 0)
|
cfg, err := tlsConfigFor("dc01.firma.local", "", 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +42,40 @@ func TestTLSConfigDefaultsToTLS12(t *testing.T) {
|
||||||
if cfg.CipherSuites != nil {
|
if cfg.CipherSuites != nil {
|
||||||
t.Error("bei TLS 1.2+ sollen Gos sichere Vorgaben gelten, keine eigene Liste")
|
t.Error("bei TLS 1.2+ sollen Gos sichere Vorgaben gelten, keine eigene Liste")
|
||||||
}
|
}
|
||||||
|
if cfg.MaxVersion != tls.VersionTLS13 {
|
||||||
|
t.Errorf("MaxVersion = %#04x, want TLS 1.3", cfg.MaxVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigCapsMaxVersion(t *testing.T) {
|
||||||
|
// Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello kommentarlos ab.
|
||||||
|
// Dann darf 1.3 gar nicht erst angeboten werden.
|
||||||
|
cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS12, tls.VersionTLS12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.MaxVersion != tls.VersionTLS12 {
|
||||||
|
t.Errorf("MaxVersion = %#04x, want TLS 1.2", cfg.MaxVersion)
|
||||||
|
}
|
||||||
|
if cfg.MinVersion != tls.VersionTLS12 {
|
||||||
|
t.Errorf("MinVersion = %#04x, want TLS 1.2", cfg.MinVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigMaxNeverBelowMin(t *testing.T) {
|
||||||
|
// Eine widerspruechliche Kombination darf keine unmoegliche Konfiguration
|
||||||
|
// erzeugen; die Validierung faengt sie ohnehin vorher ab.
|
||||||
|
cfg, err := tlsConfigFor("dc01", "", tls.VersionTLS12, tls.VersionTLS10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.MaxVersion < cfg.MinVersion {
|
||||||
|
t.Errorf("MaxVersion %#04x < MinVersion %#04x", cfg.MaxVersion, cfg.MinVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) {
|
func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) {
|
||||||
cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS10)
|
cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS10, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +122,7 @@ func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSConfigRejectsBadCAFile(t *testing.T) {
|
func TestTLSConfigRejectsBadCAFile(t *testing.T) {
|
||||||
if _, err := tlsConfigFor("dc01", "/gibt/es/nicht.pem", tls.VersionTLS12); err == nil {
|
if _, err := tlsConfigFor("dc01", "/gibt/es/nicht.pem", tls.VersionTLS12, 0); err == nil {
|
||||||
t.Fatal("fehlende CA-Datei muss abgelehnt werden")
|
t.Fatal("fehlende CA-Datei muss abgelehnt werden")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,13 @@ func directoryHint(err error) string {
|
||||||
return "Der Domain Controller spricht kein TLS 1.2. Für Altsysteme wie " +
|
return "Der Domain Controller spricht kein TLS 1.2. Für Altsysteme wie " +
|
||||||
"Windows Server 2012 R2 lässt sich ad.min_tls_version auf \"1.0\" setzen; " +
|
"Windows Server 2012 R2 lässt sich ad.min_tls_version auf \"1.0\" setzen; " +
|
||||||
"besser ist, TLS 1.2 auf dem Domain Controller zu aktivieren."
|
"besser ist, TLS 1.2 auf dem Domain Controller zu aktivieren."
|
||||||
|
case contains("connection reset by peer", "eof", "broken pipe"):
|
||||||
|
return "Der Domain Controller hat die Verbindung zurückgesetzt, ohne ein " +
|
||||||
|
"Zertifikat zu schicken. Zwei häufige Ursachen: (1) Auf dem Server ist " +
|
||||||
|
"gar kein LDAPS-Zertifikat installiert — Windows nimmt Port 636 dann " +
|
||||||
|
"zwar an, bricht aber ab. (2) Ein alter Schannel-Stack kommt mit dem " +
|
||||||
|
"TLS-1.3-ClientHello nicht zurecht; dann ad.max_tls_version auf \"1.2\" " +
|
||||||
|
"setzen. Prüfen mit: openssl s_client -connect <dc>:636 -tls1_2"
|
||||||
case contains("no cipher suite supported by both", "handshake failure"):
|
case contains("no cipher suite supported by both", "handshake failure"):
|
||||||
return "Client und Domain Controller haben keine gemeinsame Cipher-Suite. " +
|
return "Client und Domain Controller haben keine gemeinsame Cipher-Suite. " +
|
||||||
"Zuerst ad.min_tls_version prüfen. Braucht der Domain Controller zwingend " +
|
"Zuerst ad.min_tls_version prüfen. Braucht der Domain Controller zwingend " +
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,16 @@ func TestDirectoryTLSErrorsGiveActionableHints(t *testing.T) {
|
||||||
`LDAPS-Verbindung zu dc01:636: tls: no cipher suite supported by both client and server`,
|
`LDAPS-Verbindung zu dc01:636: tls: no cipher suite supported by both client and server`,
|
||||||
"tlsrsakex",
|
"tlsrsakex",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Verbindung zurueckgesetzt",
|
||||||
|
`LDAPS-Verbindung zu dc01:636: read tcp 10.0.0.5->10.0.0.250:636: read: connection reset by peer`,
|
||||||
|
"max_tls_version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Verbindung sofort geschlossen",
|
||||||
|
`LDAPS-Verbindung zu dc01:636: EOF`,
|
||||||
|
"LDAPS-Zertifikat",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,11 @@ type ADConfig struct {
|
||||||
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version zum
|
// MinTLSVersion ist die niedrigste akzeptierte TLS-Version zum
|
||||||
// Verzeichnisdienst: "1.0", "1.1", "1.2" (Default) oder "1.3".
|
// Verzeichnisdienst: "1.0", "1.1", "1.2" (Default) oder "1.3".
|
||||||
// Unter 1.2 nur für Altsysteme, die nichts Besseres können.
|
// Unter 1.2 nur für Altsysteme, die nichts Besseres können.
|
||||||
MinTLSVersion string `yaml:"min_tls_version"`
|
MinTLSVersion string `yaml:"min_tls_version"`
|
||||||
|
// MaxTLSVersion deckelt die angebotene TLS-Version, Default "1.3".
|
||||||
|
// Auf "1.2" setzen, wenn ein alter Schannel-Stack den Verbindungsaufbau
|
||||||
|
// bereits am TLS-1.3-ClientHello abbricht.
|
||||||
|
MaxTLSVersion string `yaml:"max_tls_version"`
|
||||||
BindUser string `yaml:"bind_user"`
|
BindUser string `yaml:"bind_user"`
|
||||||
BindPassword string `yaml:"bind_password"`
|
BindPassword string `yaml:"bind_password"`
|
||||||
BindPasswordFile string `yaml:"bind_password_file"`
|
BindPasswordFile string `yaml:"bind_password_file"`
|
||||||
|
|
@ -101,6 +105,7 @@ func Defaults() *Config {
|
||||||
Port: 636,
|
Port: 636,
|
||||||
TLSMode: "ldaps",
|
TLSMode: "ldaps",
|
||||||
MinTLSVersion: "1.2",
|
MinTLSVersion: "1.2",
|
||||||
|
MaxTLSVersion: "1.3",
|
||||||
Timeout: Duration(8 * time.Second),
|
Timeout: Duration(8 * time.Second),
|
||||||
},
|
},
|
||||||
Matching: MatchingConfig{CNPattern: "{username}"},
|
Matching: MatchingConfig{CNPattern: "{username}"},
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,22 @@ func (c *Config) Validate() error {
|
||||||
if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) {
|
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))
|
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) {
|
minOK := slices.Contains(MinTLSVersions, c.AD.MinTLSVersion)
|
||||||
|
maxOK := slices.Contains(MinTLSVersions, c.AD.MaxTLSVersion)
|
||||||
|
if !minOK {
|
||||||
errs = append(errs, fmt.Errorf("ad.min_tls_version %q ist ungültig (erlaubt: %s)",
|
errs = append(errs, fmt.Errorf("ad.min_tls_version %q ist ungültig (erlaubt: %s)",
|
||||||
c.AD.MinTLSVersion, strings.Join(MinTLSVersions, ", ")))
|
c.AD.MinTLSVersion, strings.Join(MinTLSVersions, ", ")))
|
||||||
}
|
}
|
||||||
|
if !maxOK {
|
||||||
|
errs = append(errs, fmt.Errorf("ad.max_tls_version %q ist ungültig (erlaubt: %s)",
|
||||||
|
c.AD.MaxTLSVersion, strings.Join(MinTLSVersions, ", ")))
|
||||||
|
}
|
||||||
|
if minOK && maxOK &&
|
||||||
|
slices.Index(MinTLSVersions, c.AD.MaxTLSVersion) < slices.Index(MinTLSVersions, c.AD.MinTLSVersion) {
|
||||||
|
errs = append(errs, fmt.Errorf(
|
||||||
|
"ad.max_tls_version %q liegt unter ad.min_tls_version %q — so kommt keine Verbindung zustande",
|
||||||
|
c.AD.MaxTLSVersion, c.AD.MinTLSVersion))
|
||||||
|
}
|
||||||
if c.AD.Port <= 0 || c.AD.Port > 65535 {
|
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))
|
errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 1–65535", c.AD.Port))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,49 @@ func TestValidateRejectsUnknownMinTLSVersion(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaxTLSVersionDefaultsTo13(t *testing.T) {
|
||||||
|
cfg := validCfg(t)
|
||||||
|
if cfg.AD.MaxTLSVersion != "1.3" {
|
||||||
|
t.Errorf("MaxTLSVersion = %q, want Default 1.3", cfg.AD.MaxTLSVersion)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("Default muss gültig sein: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAcceptsCappedMaxTLSVersion(t *testing.T) {
|
||||||
|
// Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello ab. Dann muss
|
||||||
|
// sich die Hoechstversion auf 1.2 deckeln lassen.
|
||||||
|
cfg := validCfg(t)
|
||||||
|
cfg.AD.MaxTLSVersion = "1.2"
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("max_tls_version 1.2 muss zulässig sein: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsMaxBelowMin(t *testing.T) {
|
||||||
|
cfg := validCfg(t)
|
||||||
|
cfg.AD.MinTLSVersion = "1.2"
|
||||||
|
cfg.AD.MaxTLSVersion = "1.1"
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("max_tls_version unter min_tls_version muss abgelehnt werden")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "max_tls_version") {
|
||||||
|
t.Errorf("Fehler muss das Feld nennen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsUnknownMaxTLSVersion(t *testing.T) {
|
||||||
|
for _, v := range []string{"1.4", "tls1.3", "", "13"} {
|
||||||
|
cfg := validCfg(t)
|
||||||
|
cfg.AD.MaxTLSVersion = v
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Errorf("max_tls_version %q muss abgelehnt werden", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMinTLSVersionIsWeakBelow12(t *testing.T) {
|
func TestMinTLSVersionIsWeakBelow12(t *testing.T) {
|
||||||
cfg := validCfg(t)
|
cfg := validCfg(t)
|
||||||
for v, wantWeak := range map[string]bool{"1.0": true, "1.1": true, "1.2": false, "1.3": false} {
|
for v, wantWeak := range map[string]bool{"1.0": true, "1.1": true, "1.2": false, "1.3": false} {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue