Skip to content

Commit eeb19e8

Browse files
CopilotshueybubblesCopilot
authored
Add serverCertificate parameter for byte-comparison validation with backward compatibility (#305)
* Initial plan * Implement certificate hostname validation skip for strict encryption Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix certificate verification logic to use Roots instead of Intermediates Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Add nil checks for PeerCertificates and fix duplicate comment Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Remove InsecureSkipVerify from setupTLSCertificateOnly, use VerifyPeerCertificate in setupTLSCommonName Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix intermediate certificate handling in setupTLSCommonName Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Improve comments and optimize intermediate cert handling Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix TLS handshake error by using InsecureSkipVerify with VerifyPeerCertificate callback Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Add security documentation explaining InsecureSkipVerify usage Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Use VerifyConnection instead of InsecureSkipVerify for hostname validation skip Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Update msdsn/conn_str.go avoid duplicate RootCAs assignment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update msdsn/conn_str_go115pre.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Skip hostname validation for all encryption modes when certificate is provided Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix: Use VerifyPeerCertificate instead of VerifyConnection for hostname skip VerifyConnection runs AFTER standard TLS verification (including hostname check), so it cannot be used to skip hostname validation. Changed back to using InsecureSkipVerify=true with VerifyPeerCertificate callback, which is the correct pattern for custom certificate verification that skips hostname checks. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Change certificate validation to byte comparison like Microsoft.Data.SqlClient Instead of validating certificate chain/expiry/subject, now simply compares the raw bytes of the server certificate with the provided certificate file. This matches the behavior of Microsoft.Data.SqlClient and eliminates the need for complete certificate chains. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Consolidate setupTLSCertificateOnly into conn_str.go Moved setupTLSCertificateOnly from build-tag-specific files to conn_str.go since the implementation is identical for all Go versions. This reduces code duplication and makes the codebase easier to maintain. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Replace fmt.Errorf with errors.New for simple error messages Changed fmt.Errorf() to errors.New() for error messages without format specifiers, following Go best practices. This is more idiomatic and efficient. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Update README, CHANGELOG, and version for certificate validation feature - Added documentation in README.md explaining certificate-based server validation - Included OpenSSL command example for obtaining server certificates - Added connection string examples for using certificates with encryption - Updated CHANGELOG.md with new feature description for v1.9.6 - Incremented version to v1.9.6 in version.go Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix CHANGELOG.md: only prepend v1.9.6 without renumbering earlier versions Reverted the inadvertent renumbering of earlier changelog entries. Now only v1.9.6 is added at the top while preserving the original version numbers (1.9.3, 1.9.2, 1.9.1, etc.) for all previous entries. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Add serverCertificate parameter for backward compatibility To preserve backward compatibility with the existing 'certificate' parameter: - Added new 'serverCertificate' parameter for byte-comparison validation - Reverted 'certificate' parameter to traditional X.509 chain validation - serverCertificate cannot be used with certificate or hostnameincertificate - Updated README.md with clear documentation of both parameters - Updated CHANGELOG.md to reflect the new parameter and compatibility - Updated tests to verify both validation methods - All msdsn tests pass successfully Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Revert setupTLSCommonName to original implementation for backward compatibility Restored the original setupTLSCommonName implementation that uses VerifyConnection instead of VerifyPeerCertificate. This maintains backward compatibility since: - setupTLSCommonName is only used for the 'certificate' parameter path - It is not needed for the new 'serverCertificate' parameter functionality - The original implementation has been in production and should not change unnecessarily All msdsn tests pass successfully. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Fix SetupTLS function call parameters in tds.go Fixed parameter order in msdsn.SetupTLS calls to match the updated function signature that includes both certificate and serverCertificate parameters. The function signature is: SetupTLS(certificate, serverCertificate, insecureSkipVerify, hostInCertificate, minTLSVersion) This resolves the AppVeyor build errors. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> * Address code review feedback: remove trailing whitespace and fix issue reference - Removed trailing whitespace from lines 257, 265, 271, 279, 321, and 331 in conn_str.go - Updated CHANGELOG.md to reference issue #304 instead of placeholder #xxx - Ran go fmt to ensure proper code formatting All msdsn tests pass successfully. Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: shueybubbles <2224906+shueybubbles@users.noreply.github.com> Co-authored-by: David Shiflet <david.shiflet@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 232fa60 commit eeb19e8

File tree

9 files changed

+248
-38
lines changed

9 files changed

+248
-38
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
# Changelog
2+
## 1.9.6
3+
4+
### Features
5+
6+
* Added new `serverCertificate` connection parameter for byte-for-byte certificate validation, matching Microsoft.Data.SqlClient behavior. This parameter skips hostname validation, chain validation, and expiry checks, only verifying that the server's certificate exactly matches the provided file. This is useful when the server's hostname doesn't match the certificate CN/SAN. (#304)
7+
* The existing `certificate` parameter maintains backward compatibility with traditional X.509 chain validation including hostname checks, expiry validation, and chain-of-trust verification.
8+
* `serverCertificate` cannot be used with `certificate` or `hostnameincertificate` parameters to prevent conflicting validation methods.
9+
210
## 1.9.3
311

412
### Bug fixes

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ Other supported formats are listed below.
5858
* `TrustServerCertificate`
5959
* false - Server certificate is checked. Default is false if encrypt is specified.
6060
* true - Server certificate is not checked. Default is true if encrypt is not specified. If trust server certificate is true, driver accepts any certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing.
61-
* `certificate` - The file that contains the public key certificate of the CA that signed the SQL Server certificate. The specified certificate overrides the go platform specific CA certificates. Currently, certificates of PEM type are supported.
62-
* `hostNameInCertificate` - Specifies the Common Name (CN) in the server certificate. Default value is the server host.
61+
* `certificate` - The file path to a certificate authority (CA) certificate or server certificate for traditional X.509 chain validation. The specified certificate overrides the go platform specific CA certificates. The driver validates the certificate chain, expiry, and hostname. Supports PEM and DER formats.
62+
* `serverCertificate` - The file path to a server certificate for byte-for-byte comparison validation (new in v1.9.6). The driver validates that the server's certificate exactly matches this file, skipping chain validation, expiry checks, and hostname validation. This matches Microsoft.Data.SqlClient behavior. Cannot be used with `certificate` or `hostnameincertificate`. Supports PEM and DER formats.
63+
* `hostNameInCertificate` - Specifies the Common Name (CN) in the server certificate. Default value is the server host. Used with the `certificate` parameter, not applicable for `serverCertificate`.
6364
* `tlsmin` - Specifies the minimum TLS version for negotiating encryption with the server. Recognized values are `1.0`, `1.1`, `1.2`, `1.3`. If not set to a recognized value the default value for the `tls` package will be used. The default is currently `1.2`.
6465
* `ServerSPN` - The kerberos SPN (Service Principal Name) for the server. Default is MSSQLSvc/host:port.
6566
* `Workstation ID` - The workstation name (default is the host name)
@@ -204,6 +205,66 @@ For further information on usage:
204205
* `odbc:server=localhost;user id=sa;database=master;app name=MyAppName;krb5-configfile=path/to/file;krb5-credcachefile=path/to/cache;authenticator=krb5`
205206
* `odbc:server=localhost;user id=sa;database=master;app name=MyAppName;krb5-configfile=path/to/file;krb5-realm=domain.com;krb5-keytabfile=path/to/keytabfile;authenticator=krb5`
206207

208+
### Using server certificates with encryption
209+
210+
The driver supports two ways to validate server certificates:
211+
212+
#### 1. `serverCertificate` - Byte-for-byte certificate comparison (New in v1.9.6)
213+
214+
When you provide a `serverCertificate` parameter, the driver validates the server by comparing the certificate bytes exactly with the provided file. This:
215+
- Skips hostname validation (allows mismatched hostnames)
216+
- Skips certificate chain validation and expiry checks
217+
- Only accepts connections where the server's certificate exactly matches the provided file
218+
- Matches the behavior of Microsoft.Data.SqlClient
219+
220+
This is useful when:
221+
- The server's DNS name doesn't match the certificate CN/SAN
222+
- You want to validate against a specific certificate without hostname validation
223+
- You're connecting through proxies or load balancers with different hostnames
224+
225+
**Restrictions**: `serverCertificate` cannot be used with `certificate` or `hostnameincertificate` parameters.
226+
227+
#### 2. `certificate` - Traditional chain validation (Backward compatible)
228+
229+
The `certificate` parameter performs standard X.509 certificate chain validation:
230+
- Validates the certificate chain against the provided CA certificate(s)
231+
- Checks certificate expiry and validity
232+
- Enforces hostname validation (unless `hostnameincertificate` is used)
233+
234+
#### Obtaining the server certificate
235+
236+
You can obtain a copy of the server's certificate using OpenSSL:
237+
238+
```bash
239+
openssl s_client -connect server:1433 -showcerts </dev/null 2>/dev/null | openssl x509 -outform PEM > cert.pem
240+
```
241+
242+
#### Example connection strings
243+
244+
Using `serverCertificate` for byte-comparison (skips hostname validation):
245+
246+
URL format:
247+
```
248+
sqlserver://username:password@host:1433?database=master&encrypt=true&serverCertificate=/path/to/cert.pem
249+
```
250+
251+
ADO format:
252+
```
253+
server=myserver;user id=sa;password=mypass;database=master;encrypt=true;serverCertificate=/path/to/cert.pem
254+
```
255+
256+
Using `certificate` for traditional chain validation:
257+
258+
URL format:
259+
```
260+
sqlserver://username:password@host:1433?database=master&encrypt=true&certificate=/path/to/ca.pem
261+
```
262+
263+
ADO format:
264+
```
265+
server=myserver;user id=sa;password=mypass;database=master;encrypt=true;certificate=/path/to/ca.pem
266+
```
267+
207268
### Azure Active Directory authentication
208269
209270
Azure Active Directory authentication uses temporary authentication tokens to authenticate.

money.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"github.com/shopspring/decimal"
88
)
99

10-
type Money[D decimal.Decimal|decimal.NullDecimal] struct {
10+
type Money[D decimal.Decimal | decimal.NullDecimal] struct {
1111
Decimal D
1212
}
1313

@@ -20,5 +20,5 @@ func (m Money[D]) Value() (driver.Value, error) {
2020
func (m *Money[D]) Scan(v any) error {
2121
scanner, _ := any(&m.Decimal).(sql.Scanner)
2222

23-
return scanner.Scan(v);
23+
return scanner.Scan(v)
2424
}

money_test.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestBulkInvalidString(t *testing.T) {
1717
col := columnStruct{
1818
ti: typeInfo{
1919
TypeId: typeMoneyN,
20-
Size: 8,
20+
Size: 8,
2121
},
2222
}
2323

@@ -36,7 +36,7 @@ func TestBulkInvalidType(t *testing.T) {
3636
col := columnStruct{
3737
ti: typeInfo{
3838
TypeId: typeMoneyN,
39-
Size: 8,
39+
Size: 8,
4040
},
4141
}
4242

@@ -55,7 +55,7 @@ func TestBulkMoneyN(t *testing.T) {
5555
col := columnStruct{
5656
ti: typeInfo{
5757
TypeId: typeMoneyN,
58-
Size: 8,
58+
Size: 8,
5959
},
6060
}
6161

@@ -79,7 +79,7 @@ func TestBulkMoneyPositive(t *testing.T) {
7979
col := columnStruct{
8080
ti: typeInfo{
8181
TypeId: typeMoney,
82-
Size: 8,
82+
Size: 8,
8383
},
8484
}
8585

@@ -103,7 +103,7 @@ func TestBulkMoneyNegative(t *testing.T) {
103103
col := columnStruct{
104104
ti: typeInfo{
105105
TypeId: typeMoney,
106-
Size: 8,
106+
Size: 8,
107107
},
108108
}
109109

@@ -127,7 +127,7 @@ func TestBulkMoney4Positive(t *testing.T) {
127127
col := columnStruct{
128128
ti: typeInfo{
129129
TypeId: typeMoney4,
130-
Size: 4,
130+
Size: 4,
131131
},
132132
}
133133

@@ -151,7 +151,7 @@ func TestBulkMoney4Negative(t *testing.T) {
151151
col := columnStruct{
152152
ti: typeInfo{
153153
TypeId: typeMoney4,
154-
Size: 4,
154+
Size: 4,
155155
},
156156
}
157157

@@ -222,8 +222,8 @@ func TestMoneyDecimal(t *testing.T) {
222222
s := &Stmt{}
223223

224224
res, err := s.makeParam(Money[shopspring.Decimal]{
225-
shopspring.New(-82913823232, -4),
226-
},
225+
shopspring.New(-82913823232, -4),
226+
},
227227
)
228228

229229
if err != nil {
@@ -397,7 +397,6 @@ func TestMoneyScanNullDecimal(t *testing.T) {
397397
}
398398
}
399399

400-
401400
func readMoney(buf []byte) int64 {
402401
return int64((uint64(binary.LittleEndian.Uint32(buf)) << 32) | uint64(binary.LittleEndian.Uint32(buf[4:])))
403402
}

msdsn/conn_str.go

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package msdsn
22

33
import (
4+
"bytes"
45
"crypto/tls"
56
"crypto/x509"
67
"encoding/pem"
@@ -65,6 +66,7 @@ const (
6566
Port = "port"
6667
TrustServerCertificate = "trustservercertificate"
6768
Certificate = "certificate"
69+
ServerCertificate = "servercertificate"
6870
TLSMin = "tlsmin"
6971
PacketSize = "packet size"
7072
LogParam = "log"
@@ -193,7 +195,9 @@ func readCertificate(certificate string) ([]byte, error) {
193195
}
194196

195197
// Build a tls.Config object from the supplied certificate.
196-
func SetupTLS(certificate string, insecureSkipVerify bool, hostInCertificate string, minTLSVersion string) (*tls.Config, error) {
198+
// serverCertificate is used for byte-comparison validation (skips chain validation and hostname validation)
199+
// certificate is used for traditional chain validation
200+
func SetupTLS(certificate string, serverCertificate string, insecureSkipVerify bool, hostInCertificate string, minTLSVersion string) (*tls.Config, error) {
197201
config := tls.Config{
198202
ServerName: hostInCertificate,
199203
InsecureSkipVerify: insecureSkipVerify,
@@ -206,13 +210,27 @@ func SetupTLS(certificate string, insecureSkipVerify bool, hostInCertificate str
206210
MinVersion: TLSVersionFromString(minTLSVersion),
207211
}
208212

213+
// Handle serverCertificate parameter (byte-comparison validation)
214+
if len(serverCertificate) > 0 {
215+
pem, err := readCertificate(serverCertificate)
216+
if err != nil {
217+
return nil, fmt.Errorf("cannot read server certificate %q: %w", serverCertificate, err)
218+
}
219+
if err := setupTLSServerCertificateOnly(&config, pem); err != nil {
220+
return nil, err
221+
}
222+
return &config, nil
223+
}
224+
225+
// Handle certificate parameter (traditional chain validation)
209226
if len(certificate) == 0 {
210227
return &config, nil
211228
}
212229
pem, err := readCertificate(certificate)
213230
if err != nil {
214231
return nil, fmt.Errorf("cannot read certificate %q: %w", certificate, err)
215232
}
233+
216234
if strings.Contains(config.ServerName, ":") && !insecureSkipVerify {
217235
err := setupTLSCommonName(&config, pem)
218236
if err != skipSetup {
@@ -225,6 +243,45 @@ func SetupTLS(certificate string, insecureSkipVerify bool, hostInCertificate str
225243
return &config, nil
226244
}
227245

246+
// setupTLSServerCertificateOnly validates that the server certificate matches the provided certificate via byte comparison
247+
// This matches the behavior of Microsoft.Data.SqlClient
248+
func setupTLSServerCertificateOnly(config *tls.Config, pemData []byte) error {
249+
// To match the behavior of Microsoft.Data.SqlClient, we simply compare the raw bytes
250+
// of the server's certificate with the provided certificate file. This approach:
251+
// - Does not validate certificate chain, expiry, or subject
252+
// - Only checks that the server's certificate exactly matches the provided certificate
253+
// - Skips hostname validation (which is the intended behavior)
254+
//
255+
// We use InsecureSkipVerify=true with VerifyPeerCertificate callback because
256+
// VerifyConnection runs AFTER standard verification (including hostname check).
257+
258+
// Parse the expected certificate from the PEM data
259+
block, _ := pem.Decode(pemData)
260+
if block == nil {
261+
return errors.New("failed to decode PEM certificate")
262+
}
263+
// Store the raw certificate bytes (DER format) for comparison
264+
expectedCertBytes := block.Bytes
265+
266+
config.InsecureSkipVerify = true
267+
config.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
268+
if len(rawCerts) == 0 {
269+
return errors.New("no peer certificates provided")
270+
}
271+
272+
// Compare the server's certificate bytes with the expected certificate bytes
273+
// This matches the Microsoft.Data.SqlClient behavior: just compare raw bytes
274+
serverCertBytes := rawCerts[0]
275+
276+
if !bytes.Equal(serverCertBytes, expectedCertBytes) {
277+
return errors.New("server certificate doesn't match the provided certificate")
278+
}
279+
280+
return nil
281+
}
282+
return nil
283+
}
284+
228285
// Parse and handle encryption parameters. If encryption is desired, it returns the corresponding tls.Config object.
229286
func parseTLS(params map[string]string, host string) (Encryption, *tls.Config, error) {
230287
trustServerCert := false
@@ -259,12 +316,25 @@ func parseTLS(params map[string]string, host string) (Encryption, *tls.Config, e
259316
}
260317
}
261318
certificate := params[Certificate]
319+
serverCertificate := params[ServerCertificate]
320+
hostInCertificate := params[HostNameInCertificate]
321+
322+
// Validate parameter combinations
323+
if len(serverCertificate) > 0 {
324+
if len(certificate) > 0 {
325+
return encryption, nil, errors.New("cannot specify both 'certificate' and 'serverCertificate' parameters")
326+
}
327+
if len(hostInCertificate) > 0 {
328+
return encryption, nil, errors.New("cannot specify both 'serverCertificate' and 'hostnameincertificate' parameters")
329+
}
330+
}
331+
262332
if encryption != EncryptionDisabled {
263333
tlsMin := params[TLSMin]
264334
if encrypt == "strict" {
265335
trustServerCert = false
266336
}
267-
tlsConfig, err := SetupTLS(certificate, trustServerCert, host, tlsMin)
337+
tlsConfig, err := SetupTLS(certificate, serverCertificate, trustServerCert, host, tlsMin)
268338
if err != nil {
269339
return encryption, nil, fmt.Errorf("failed to setup TLS: %w", err)
270340
}
@@ -711,11 +781,11 @@ func splitAdoConnectionStringParts(dsn string) []string {
711781
var parts []string
712782
var current strings.Builder
713783
inQuotes := false
714-
784+
715785
runes := []rune(dsn)
716786
for i := 0; i < len(runes); i++ {
717787
char := runes[i]
718-
788+
719789
if char == '"' {
720790
if inQuotes && i+1 < len(runes) && runes[i+1] == '"' {
721791
// Double quote escape sequence - add both quotes to current part
@@ -735,12 +805,12 @@ func splitAdoConnectionStringParts(dsn string) []string {
735805
current.WriteRune(char)
736806
}
737807
}
738-
808+
739809
// Add the last part if it's not empty
740810
if current.Len() > 0 {
741811
parts = append(parts, current.String())
742812
}
743-
813+
744814
return parts
745815
}
746816

msdsn/conn_str_go115pre.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
package msdsn
55

6-
import "crypto/tls"
6+
import (
7+
"crypto/tls"
8+
)
79

8-
func setupTLSCommonName(config *tls.Config, pem []byte) error {
10+
func setupTLSCommonName(config *tls.Config, pemData []byte) error {
911
// Prior to Go 1.15, the TLS allowed ":" when checking the hostname.
1012
// See https://golang.org/issue/40748 for details.
1113
return skipSetup

0 commit comments

Comments
 (0)