Skip to content

Commit ef39c76

Browse files
Add support for MySQL VERIFY_CA SSL mode via tls-verify parameter
Implements VERIFY_CA SSL mode to verify certificate authority without hostname verification, matching MySQL client's --ssl-mode=VERIFY_CA. This addresses a long-standing limitation where users needed TLS with CA verification but couldn't use hostname verification due to: - Connecting via IP addresses instead of hostnames - Dynamic IPs or load-balanced MySQL instances - Certificates with SANs that don't match connection strings - Multiple hostnames for the same MySQL instance Adds new DSN parameter with two values: - identity: Full verification (CA + hostname) - default - ca: CA verification only (no hostname check) Works with both system CA pool and custom registered TLS configs: - ?tls=true&tls-verify=ca (system CA) - ?tls=custom&tls-verify=ca (custom CA) This is particularly important for users migrating to MySQL 8.0's caching_sha2_password authentication, which requires encrypted connections by default, making TLS support more critical. Implementation follows Go team's recommended pattern from golang/go issues #21971, #31791, #31792, #35467: using InsecureSkipVerify with custom VerifyPeerCertificate callback that performs CA validation via x509.Certificate.Verify() without hostname checking. Related: #899 See also: golang/go#31792, golang/go#24151, golang/go#21971, golang/go#28754, golang/go#31791, golang/go#35467
1 parent 76c00e3 commit ef39c76

File tree

6 files changed

+352
-2
lines changed

6 files changed

+352
-2
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Diego Dupin <diego.dupin at gmail.com>
4343
Dirkjan Bussink <d.bussink at gmail.com>
4444
DisposaBoy <disposaboy at dby.me>
4545
Egor Smolyakov <egorsmkv at gmail.com>
46+
Ehsan Pourtorab <pourtorab.ehsan at gmail.com>
4647
Erwan Martin <hello at erwan.io>
4748
Evan Elias <evan at skeema.net>
4849
Evan Shaw <evan at vendhq.com>

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,39 @@ Valid Values: true, false, skip-verify, preferred, <name>
434434
Default: false
435435
```
436436

437-
`tls=true` enables TLS / SSL encrypted connection to the server. Use `skip-verify` if you want to use a self-signed or invalid certificate (server side) or use `preferred` to use TLS only when advertised by the server. This is similar to `skip-verify`, but additionally allows a fallback to a connection which is not encrypted. Neither `skip-verify` nor `preferred` add any reliable security. You can use a custom TLS config after registering it with [`mysql.RegisterTLSConfig`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterTLSConfig).
437+
`tls=true` enables TLS / SSL encrypted connection to the server with full certificate verification (including hostname). Use `skip-verify` if you want to use a self-signed or invalid certificate (server side) or use `preferred` to use TLS only when advertised by the server. This is similar to `skip-verify`, but additionally allows a fallback to a connection which is not encrypted. Neither `skip-verify` nor `preferred` add any reliable security. You can use a custom TLS config after registering it with [`mysql.RegisterTLSConfig`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterTLSConfig).
438+
439+
**TLS Verification Modes:**
440+
441+
The `tls` parameter selects which CA certificates to use:
442+
- `tls=true`: Use system CA pool
443+
- `tls=<name>`: Use custom registered TLS config
444+
- `tls=skip-verify`: Accept any certificate (insecure)
445+
- `tls=preferred`: Attempt TLS, fall back to plaintext (insecure)
446+
447+
The `tls-verify` parameter controls how certificates are verified (works with both `tls=true` and custom configs):
448+
- `tls-verify=identity` (default): Verifies CA and hostname - Most secure, equivalent to MySQL's VERIFY_IDENTITY
449+
- `tls-verify=ca`: Verifies CA only, skips hostname check - Equivalent to MySQL's VERIFY_CA mode
450+
451+
**Examples:**
452+
- `?tls=true` - System CA with full verification (default behavior)
453+
- `?tls=true&tls-verify=ca` - System CA with CA-only verification
454+
- `?tls=custom` - Custom CA with full verification (default behavior)
455+
- `?tls=custom&tls-verify=ca` - Custom CA with CA-only verification
456+
457+
##### `tls-verify`
458+
459+
```
460+
Type: string
461+
Valid Values: identity, ca
462+
Default: identity
463+
```
464+
465+
Controls the TLS certificate verification level. This parameter works in conjunction with the `tls` parameter:
466+
- `identity`: Full verification including hostname (default, most secure)
467+
- `ca`: CA verification only, without hostname checking (MySQL VERIFY_CA equivalent)
468+
469+
This parameter only applies when `tls=true` or `tls=<custom-config>`. It has no effect with `tls=skip-verify` or `tls=preferred`.
438470

439471

440472
##### `writeTimeout`

dsn.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Config struct {
4949
MaxAllowedPacket int // Max packet size allowed
5050
ServerPubKey string // Server public key name
5151
TLSConfig string // TLS configuration name
52+
TLSVerify string // TLS verification level: "identity" (default) or "ca"
5253
TLS *tls.Config // TLS configuration, its priority is higher than TLSConfig
5354
Timeout time.Duration // Dial timeout
5455
ReadTimeout time.Duration // I/O read timeout
@@ -195,21 +196,39 @@ func (cfg *Config) normalize() error {
195196
}
196197

197198
if cfg.TLS == nil {
199+
// Default TLSVerify to identity if not specified
200+
if cfg.TLSVerify == "" {
201+
cfg.TLSVerify = "identity"
202+
}
203+
198204
switch cfg.TLSConfig {
199205
case "false", "":
200206
// don't set anything
201207
case "true":
202-
cfg.TLS = &tls.Config{}
208+
// System CA pool
209+
if cfg.TLSVerify == "ca" {
210+
cfg.TLS = createVerifyCAConfig(nil)
211+
} else {
212+
cfg.TLS = &tls.Config{}
213+
}
203214
case "skip-verify":
204215
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
205216
case "preferred":
206217
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
207218
cfg.AllowFallbackToPlaintext = true
208219
default:
220+
// Custom registered TLS config
209221
cfg.TLS = getTLSConfigClone(cfg.TLSConfig)
210222
if cfg.TLS == nil {
211223
return errors.New("invalid value / unknown config name: " + cfg.TLSConfig)
212224
}
225+
226+
// Apply tls-verify to custom config
227+
if cfg.TLSVerify == "ca" {
228+
// Extract RootCAs from the custom config and create a CA-only verification config
229+
rootCAs := cfg.TLS.RootCAs
230+
cfg.TLS = createVerifyCAConfig(rootCAs)
231+
}
213232
}
214233
}
215234

@@ -370,6 +389,10 @@ func (cfg *Config) FormatDSN() string {
370389
writeDSNParam(&buf, &hasParam, "tls", url.QueryEscape(cfg.TLSConfig))
371390
}
372391

392+
if len(cfg.TLSVerify) > 0 && cfg.TLSVerify != "identity" {
393+
writeDSNParam(&buf, &hasParam, "tls-verify", cfg.TLSVerify)
394+
}
395+
373396
if cfg.WriteTimeout > 0 {
374397
writeDSNParam(&buf, &hasParam, "writeTimeout", cfg.WriteTimeout.String())
375398
}
@@ -658,6 +681,14 @@ func parseDSNParams(cfg *Config, params string) (err error) {
658681
cfg.TLSConfig = name
659682
}
660683

684+
// TLS verification level
685+
case "tls-verify":
686+
mode := strings.ToLower(value)
687+
if mode != "identity" && mode != "ca" {
688+
return fmt.Errorf("invalid tls-verify value: %s (must be 'identity' or 'ca')", value)
689+
}
690+
cfg.TLSVerify = mode
691+
661692
// I/O write Timeout
662693
case "writeTimeout":
663694
cfg.WriteTimeout, err = time.ParseDuration(value)

dsn_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,160 @@ func TestNormalizeTLSConfig(t *testing.T) {
429429
}
430430
}
431431

432+
func TestTLSVerifySystemCA(t *testing.T) {
433+
tests := []struct {
434+
name string
435+
dsn string
436+
}{
437+
{"ca with system CA", "tcp(example.com:1234)/?tls=true&tls-verify=ca"},
438+
{"identity with system CA (explicit)", "tcp(example.com:1234)/?tls=true&tls-verify=identity"},
439+
{"identity with system CA (default)", "tcp(example.com:1234)/?tls=true"},
440+
}
441+
442+
for _, tc := range tests {
443+
t.Run(tc.name, func(t *testing.T) {
444+
cfg, err := ParseDSN(tc.dsn)
445+
if err != nil {
446+
t.Error(err.Error())
447+
}
448+
if cfg.TLS == nil {
449+
t.Error("cfg.TLS should not be nil")
450+
}
451+
452+
if cfg.TLSVerify == "ca" {
453+
if !cfg.TLS.InsecureSkipVerify {
454+
t.Error("ca mode should have InsecureSkipVerify=true")
455+
}
456+
if cfg.TLS.VerifyPeerCertificate == nil {
457+
t.Error("ca mode should have VerifyPeerCertificate callback set")
458+
}
459+
// ca should not set ServerName (hostname verification is skipped)
460+
if cfg.TLS.ServerName != "" {
461+
t.Errorf("ca mode should not set ServerName, got %q", cfg.TLS.ServerName)
462+
}
463+
} else {
464+
// identity (default) should set ServerName
465+
if cfg.TLS.ServerName != "example.com" {
466+
t.Errorf("identity mode should set ServerName to 'example.com', got %q", cfg.TLS.ServerName)
467+
}
468+
}
469+
})
470+
}
471+
}
472+
473+
func TestTLSVerifyCustomConfig(t *testing.T) {
474+
// Register a custom TLS config
475+
customConfig := &tls.Config{
476+
ServerName: "customServer",
477+
RootCAs: nil, // Use system CA pool for this test
478+
}
479+
RegisterTLSConfig("custom", customConfig)
480+
defer DeregisterTLSConfig("custom")
481+
482+
tests := []struct {
483+
name string
484+
dsn string
485+
}{
486+
{"ca with custom config", "tcp(example.com:1234)/?tls=custom&tls-verify=ca"},
487+
{"identity with custom config (explicit)", "tcp(example.com:1234)/?tls=custom&tls-verify=identity"},
488+
{"identity with custom config (default)", "tcp(example.com:1234)/?tls=custom"},
489+
}
490+
491+
for _, tc := range tests {
492+
t.Run(tc.name, func(t *testing.T) {
493+
cfg, err := ParseDSN(tc.dsn)
494+
if err != nil {
495+
t.Error(err.Error())
496+
}
497+
if cfg.TLS == nil {
498+
t.Error("cfg.TLS should not be nil")
499+
}
500+
501+
if cfg.TLSVerify == "ca" {
502+
if !cfg.TLS.InsecureSkipVerify {
503+
t.Error("ca mode should have InsecureSkipVerify=true")
504+
}
505+
if cfg.TLS.VerifyPeerCertificate == nil {
506+
t.Error("ca mode should have VerifyPeerCertificate callback set")
507+
}
508+
// ca should not set ServerName (hostname verification is skipped)
509+
if cfg.TLS.ServerName != "" {
510+
t.Errorf("ca mode should not set ServerName, got %q", cfg.TLS.ServerName)
511+
}
512+
} else {
513+
// identity (default) should preserve custom config's ServerName
514+
if cfg.TLS.ServerName != "customServer" {
515+
t.Errorf("identity mode should preserve custom ServerName 'customServer', got %q", cfg.TLS.ServerName)
516+
}
517+
}
518+
})
519+
}
520+
}
521+
522+
func TestTLSVerifyBackwardsCompatibility(t *testing.T) {
523+
tests := []struct {
524+
name string
525+
dsn string
526+
expectTLSVerify string
527+
expectServerName string
528+
}{
529+
{"tls=true defaults to identity", "tcp(example.com:1234)/?tls=true", "identity", "example.com"},
530+
{"tls=false no TLS", "tcp(example.com:1234)/?tls=false", "identity", ""},
531+
{"tls=skip-verify unchanged", "tcp(example.com:1234)/?tls=skip-verify", "identity", ""},
532+
{"tls=preferred unchanged", "tcp(example.com:1234)/?tls=preferred", "identity", ""},
533+
}
534+
535+
for _, tc := range tests {
536+
t.Run(tc.name, func(t *testing.T) {
537+
cfg, err := ParseDSN(tc.dsn)
538+
if err != nil {
539+
t.Error(err.Error())
540+
}
541+
542+
if cfg.TLSVerify != tc.expectTLSVerify {
543+
t.Errorf("expected TLSVerify=%q, got %q", tc.expectTLSVerify, cfg.TLSVerify)
544+
}
545+
546+
if tc.expectServerName == "" {
547+
if cfg.TLS == nil {
548+
return // Expected no TLS
549+
}
550+
if cfg.TLS.ServerName != "" {
551+
t.Errorf("expected no ServerName, got %q", cfg.TLS.ServerName)
552+
}
553+
} else {
554+
if cfg.TLS == nil {
555+
t.Error("expected TLS config but got nil")
556+
return
557+
}
558+
if cfg.TLS.ServerName != tc.expectServerName {
559+
t.Errorf("expected ServerName=%q, got %q", tc.expectServerName, cfg.TLS.ServerName)
560+
}
561+
}
562+
})
563+
}
564+
}
565+
566+
func TestTLSVerifyInvalidValue(t *testing.T) {
567+
dsn := "tcp(example.com:1234)/?tls=true&tls-verify=invalid"
568+
_, err := ParseDSN(dsn)
569+
if err == nil {
570+
t.Error("expected error for invalid tls-verify value")
571+
}
572+
}
573+
574+
func TestRegisterTLSConfigReservedKey(t *testing.T) {
575+
reservedKeys := []string{"true", "false", "skip-verify", "preferred"}
576+
577+
for _, key := range reservedKeys {
578+
err := RegisterTLSConfig(key, &tls.Config{})
579+
if err == nil {
580+
t.Errorf("RegisterTLSConfig should reject reserved key %q", key)
581+
}
582+
DeregisterTLSConfig(key) // Clean up in case it was registered
583+
}
584+
}
585+
432586
func BenchmarkParseDSN(b *testing.B) {
433587
b.ReportAllocs()
434588

utils.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package mysql
1010

1111
import (
1212
"crypto/tls"
13+
"crypto/x509"
1314
"database/sql"
1415
"database/sql/driver"
1516
"encoding/binary"
@@ -87,6 +88,68 @@ func getTLSConfigClone(key string) (config *tls.Config) {
8788
return
8889
}
8990

91+
// createVerifyCAConfig creates a TLS config that verifies the CA certificate
92+
// but does not verify the hostname. This implements MySQL's VERIFY_CA mode.
93+
// It uses the recommended Go pattern from issues #21971, #31791, #31792, #35467:
94+
// 1. Set InsecureSkipVerify to disable default verification
95+
// 2. Use VerifyPeerCertificate callback to manually verify CA without hostname
96+
//
97+
// If rootCAs is nil, the system's default CA pool will be used.
98+
// If rootCAs is provided, it will be used for verification instead.
99+
func createVerifyCAConfig(rootCAs *x509.CertPool) *tls.Config {
100+
return &tls.Config{
101+
InsecureSkipVerify: true,
102+
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
103+
return verifyCACallback(rawCerts, verifiedChains, rootCAs)
104+
},
105+
}
106+
}
107+
108+
// verifyCACallback implements CA-only verification without hostname checking.
109+
// This verifies that the certificate chain is signed by a trusted CA but does
110+
// not validate the hostname matches the certificate. This is the standard
111+
// implementation pattern recommended by the Go team for VERIFY_CA behavior.
112+
//
113+
// If rootCAs is nil, the system's default CA pool will be used.
114+
// If rootCAs is provided, it will be used for verification instead.
115+
func verifyCACallback(rawCerts [][]byte, verifiedChains [][]*x509.Certificate, rootCAs *x509.CertPool) error {
116+
if len(rawCerts) == 0 {
117+
return errors.New("tls: no certificates from server")
118+
}
119+
120+
// Parse all certificates in the chain
121+
certs := make([]*x509.Certificate, len(rawCerts))
122+
for i, rawCert := range rawCerts {
123+
cert, err := x509.ParseCertificate(rawCert)
124+
if err != nil {
125+
return fmt.Errorf("tls: failed to parse certificate: %w", err)
126+
}
127+
certs[i] = cert
128+
}
129+
130+
// Build intermediates pool from all certificates except the first (leaf)
131+
intermediates := x509.NewCertPool()
132+
for _, cert := range certs[1:] {
133+
intermediates.AddCert(cert)
134+
}
135+
136+
// Verify the certificate chain without hostname verification
137+
// By not setting DNSName in VerifyOptions, we skip hostname validation
138+
// This implements VERIFY_CA behavior (CA check only, no hostname check)
139+
opts := x509.VerifyOptions{
140+
Roots: rootCAs, // nil = use system CA pool
141+
Intermediates: intermediates,
142+
// DNSName is intentionally not set to skip hostname verification
143+
}
144+
145+
_, err := certs[0].Verify(opts)
146+
if err != nil {
147+
return fmt.Errorf("tls: failed to verify certificate: %w", err)
148+
}
149+
150+
return nil
151+
}
152+
90153
// Returns the bool value of the input.
91154
// The 2nd return value indicates if the input was a valid bool value
92155
func readBool(input string) (value bool, valid bool) {

0 commit comments

Comments
 (0)